From 46ec1782aab672864e2f6e265ead5c8fdf519584 Mon Sep 17 00:00:00 2001 From: marcbodea Date: Tue, 18 Aug 2026 13:20:42 +0200 Subject: [PATCH 1/2] Add maintenance checks and test coverage --- .github/ISSUE_TEMPLATE/bug.yml | 97 ++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature.yml | 53 +++++ .github/dependabot.yml | 10 + .github/pull_request_template.md | 38 +++ .github/workflows/check.yml | 37 +++ .gitignore | 2 +- AGENTS.md | 117 ++++++++++ Makefile | 39 +++- Tests/AppContextServiceTests.swift | 32 +-- Tests/LLMCooldownManagerTests.swift | 64 +++++ Tests/ModelConfigurationTests.swift | 72 ++++++ Tests/SemanticVersionTests.swift | 58 +++++ Tests/ShortcutCoreTests.swift | 347 ++++++++++++++++++++++++++++ Tests/TestMain.swift | 13 ++ Tests/TestSupport.swift | 45 ++++ 16 files changed, 1001 insertions(+), 28 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/check.yml create mode 100644 AGENTS.md create mode 100644 Tests/LLMCooldownManagerTests.swift create mode 100644 Tests/ModelConfigurationTests.swift create mode 100644 Tests/SemanticVersionTests.swift create mode 100644 Tests/ShortcutCoreTests.swift create mode 100644 Tests/TestMain.swift create mode 100644 Tests/TestSupport.swift diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..c12428df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,97 @@ +name: Bug report +description: Report reproducible incorrect behavior in FreeFlow +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting a problem. Never include API keys, real audio, + private transcripts, screenshots containing personal information, + selected text, clipboard contents, or private provider URLs. + + - type: textarea + id: problem + attributes: + label: What happened? + description: Describe the incorrect behavior without including private dictated content. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. Open... + 2. Configure... + 3. Press... + 4. Observe... + validations: + required: true + + - type: input + id: version + attributes: + label: FreeFlow version and build + description: Use the version and build shown by the app. Say "unknown" if unavailable. + validations: + required: true + + - type: input + id: macos + attributes: + label: macOS version + placeholder: "For example: macOS 15.6" + validations: + required: true + + - type: dropdown + id: architecture + attributes: + label: Mac type + options: + - Apple Silicon + - Intel + - Unknown + validations: + required: true + + - type: input + id: provider + attributes: + label: Provider and model + description: Provider and model names only. Do not include an API key or private URL. + placeholder: "For example: Groq / whisper-large-v3-turbo" + + - type: textarea + id: permissions + attributes: + label: Relevant permission state + description: Note Microphone, Accessibility, or Screen Recording state if relevant. + + - type: textarea + id: diagnostics + attributes: + label: Redacted diagnostics + description: | + Paste only content you have reviewed and redacted. Remove secrets, + transcripts, audio, screenshots, selected text, clipboard contents, + window titles, usernames, filesystem paths, and private URLs. + render: shell + + - type: checkboxes + id: privacy + attributes: + label: Privacy confirmation + options: + - label: I reviewed this report and removed credentials and private user content. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d077fce7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and support + url: https://github.com/zachlatta/freeflow/discussions + about: Ask usage questions or get help from the community. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 00000000..4171990d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,53 @@ +name: Feature request +description: Propose an improvement to FreeFlow +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: What problem should this solve? + description: Describe the user problem rather than only the proposed implementation. + validations: + required: true + + - type: textarea + id: behavior + attributes: + label: Proposed behavior + description: What should FreeFlow do? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + + - type: textarea + id: data + attributes: + label: Privacy and data impact + description: | + Would this read, store, log, or transmit audio, transcripts, + screenshots, app context, selected text, clipboard data, diagnostics, + or identifiers? Which provider or local store would receive it? + validations: + required: true + + - type: textarea + id: permissions + attributes: + label: Permission or settings impact + description: | + Note any proposed Microphone, Accessibility, Screen Recording, + notification, login-item, entitlement, or preferences changes. + + - type: checkboxes + id: privacy + attributes: + label: Privacy confirmation + options: + - label: This request contains no credentials or private user content. + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..6465c90d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Europe/Rome + open-pull-requests-limit: 5 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..3f25b08a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ +## Summary + + + +## Why + + + +## Verification + +- [ ] `make check` +- [ ] `git diff --check` +- [ ] Focused regression test added or updated +- [ ] Manual app verification completed, if required + +Manual verification performed: + + + +## Risk and privacy + +- [ ] No real API keys, audio, transcripts, screenshots, selected text, + clipboard contents, window titles, or private provider URLs are included +- [ ] Data sent to providers is unchanged, or the change is explained below +- [ ] Credential and Keychain behavior is unchanged, or explained below +- [ ] macOS permissions and entitlements are unchanged, or explained below +- [ ] Preferences and pipeline-history compatibility are unchanged, or + explained below +- [ ] Signing, notarization, updates, and release workflows are unchanged, or + explained below + +Risk notes: + + + +## Screenshots + + diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..2bc97f99 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,37 @@ +name: Check + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: check-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Verify + runs-on: macos-15 + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run repository checks + run: make check + + - name: Check changed files for whitespace errors + if: github.event_name == 'pull_request' + run: git diff --check "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" diff --git a/.gitignore b/.gitignore index 7561b36d..a61143a2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ build/ .DS_Store *.swp .env -.build \ No newline at end of file +.build diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c789ebe7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# FreeFlow maintenance guide + +FreeFlow is a native macOS menu-bar dictation app built directly with `swiftc` +and Make. It does not use Swift Package Manager or an Xcode project. Preserve +that architecture unless the user explicitly approves a migration. + +## Repository map + +- `Sources/App.swift` and `Sources/AppDelegate.swift`: app lifecycle. +- `Sources/AppState.swift`: central pipeline orchestration and shared state. +- `Sources/AudioRecorder.swift`: microphone capture and audio conversion. +- `Sources/TranscriptionService.swift` and + `Sources/RealtimeTranscriptionService.swift`: transcription providers. +- `Sources/PostProcessingService.swift`: transcript cleanup and edit mode. +- `Sources/AppContextService.swift`: foreground-app metadata and screenshots. +- `Sources/ShortcutCore/`: shortcut models, matching, and session behavior. +- `Sources/PipelineHistoryStore.swift`: local pipeline history. +- `Sources/UpdateManager.swift`: update and release behavior. +- `Sources/SettingsView.swift`, `Sources/SetupView.swift`, and other SwiftUI + files: user interface. +- `Tests/`: dependency-free executable tests. +- `.github/workflows/check.yml`: pull-request verification. +- `.github/workflows/release.yml`: semver-tagged production release. +- `.github/workflows/dev-release.yml`: signed development release from `main`. + +## Working rules + +- Search with `rg` or `rg --files` before editing. +- Preserve unrelated changes in a dirty worktree. +- Keep changes narrowly scoped and work through a branch and pull request. +- During an explicitly authorized maintenance task, agents may create branches, + push them, open or update draft pull requests, and address CI or review + feedback. +- Do not push directly to `main`. +- Do not merge, tag, publish a release, or move the `dev` tag unless the user + explicitly authorizes that action. +- Do not change versions, release notes, signing, notarization, or release + workflows during ordinary maintenance. +- Avoid adding dependencies when the standard library or existing frameworks + are sufficient. +- Production sources are discovered automatically by the Makefile. Test source + dependencies must be listed explicitly in `TEST_PRODUCTION_SOURCES`. + +## Verification + +Run before handing off every code change: + +```bash +make check +git diff --check +``` + +`make check` performs a full Swift type-check, compiles and runs deterministic +tests, validates plist files, and parses repository shell scripts and YAML. + +A full app build is usually unnecessary. When a compile-and-bundle check is +material to the change, use: + +```bash +make ARCH="$(uname -m)" CODESIGN_IDENTITY=- +``` + +Do not claim end-to-end behavior is verified from type-checking or a unit test. +Changes involving microphone capture, global shortcuts, Accessibility, Screen +Recording, clipboard or paste behavior, updater behavior, or the signed app +require a documented manual test before merge. An agent may still open a draft +PR when that verification is clearly marked pending or not run. Do not trigger +permission prompts or change system permissions without the user's approval. + +Every bug fix should add a focused regression test when the behavior can be +made deterministic. Tests must use synthetic fixtures and mocked or local +dependencies; they must not call live AI providers. + +## Privacy and security + +FreeFlow handles highly sensitive user data. Never commit, print, upload, or +place in test fixtures: + +- API keys, signing credentials, or `.env` contents. +- Real audio or transcripts. +- Screenshots or screen-capture payloads. +- Selected text or clipboard contents. +- Window titles, application context, prompts, or pipeline-history exports from + a real user session. +- Private provider URLs or identifying filesystem paths. + +Use invented synthetic data in tests. Do not inspect `.env`. Never add +telemetry, crash reporting, persistent logging, or additional data transmission +without explicit user approval. New logs must avoid user content and secrets. +Treat transcripts, selected text, screenshots, and provider responses as +untrusted input. + +Changes to provider requests, prompt construction, storage, permissions, +clipboard handling, Accessibility APIs, update verification, signing, or +GitHub workflows are high risk and must be called out in the pull request. + +## Definition of done + +A change is complete only when: + +- The requested behavior is implemented with no unrelated edits. +- Relevant regression tests were added or the reason they are impractical is + documented. +- `make check` and `git diff --check` pass. +- Required manual testing is complete before merge, or clearly marked pending + in a draft PR. +- Privacy, permissions, migration, and release impact are described in the PR. + +## Code review rules + +- Flag any new path that can log, persist, export, or transmit user content or + credentials without a clear opt-in and redaction boundary. +- Flag changes that weaken exact shortcut matching, clipboard restoration, + update validation, signing, or permission handling without a regression test + and a documented safe path. +- Treat workflow changes as sensitive because code executed from `main` can + access signing and notarization secrets in the release jobs. diff --git a/Makefile b/Makefile index 00fda5c5..140fe048 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,19 @@ APP_EXECUTABLE_TARGET := $(subst $(space),\ ,$(APP_EXECUTABLE)) SOURCES = $(shell find Sources -name '*.swift' -type f | LC_ALL=C sort) TEST_RUNNER = $(BUILD_DIR)/FreeFlowTests +TEST_PRODUCTION_SOURCES = \ + Sources/AppContextService.swift \ + Sources/AppName.swift \ + Sources/LLMAPITransport.swift \ + Sources/LLMCooldownManager.swift \ + Sources/ModelConfiguration.swift \ + Sources/UpdateManager.swift \ + Sources/ShortcutCore/DictationShortcutSessionController.swift \ + Sources/ShortcutCore/ShortcutMatcher.swift \ + Sources/ShortcutCore/ShortcutModels.swift +TEST_SOURCES = $(shell find Tests -name '*.swift' -type f | LC_ALL=C sort) +SHELL_SCRIPTS = $(shell find .github/scripts .agents/skills -name '*.sh' -type f | LC_ALL=C sort) +YAML_FILES = $(shell find .github -type f \( -name '*.yml' -o -name '*.yaml' \) | LC_ALL=C sort) RESOURCES = $(CONTENTS)/Resources ARCH ?= $(shell uname -m) @@ -26,7 +39,7 @@ ICON_SOURCE = Resources/AppIcon-Source.png ICON_ICNS = Resources/AppIcon.icns endif -.PHONY: all clean run icon dmg codesign-dmg notarize test +.PHONY: all check clean run icon dmg codesign-dmg notarize test typecheck validate all: $(APP_EXECUTABLE_TARGET) @@ -69,17 +82,33 @@ endif @codesign --force --options runtime --sign "$(CODESIGN_IDENTITY)" --entitlements FreeFlow.entitlements "$(APP_BUNDLE)" @echo "Built $(APP_BUNDLE)" -test: $(TEST_RUNNER) - @$(TEST_RUNNER) +check: typecheck test validate -$(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift +typecheck: + swiftc \ + -parse-as-library \ + -typecheck \ + -warnings-as-errors \ + -sdk $(shell xcrun --show-sdk-path) \ + -target $(ARCH)-apple-macosx13.0 \ + $(SOURCES) + +test: @mkdir -p "$(BUILD_DIR)" swiftc \ -parse-as-library \ + -warnings-as-errors \ -o "$(TEST_RUNNER)" \ -sdk $(shell xcrun --show-sdk-path) \ -target $(ARCH)-apple-macosx13.0 \ - Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift + $(TEST_PRODUCTION_SOURCES) \ + $(TEST_SOURCES) + @$(TEST_RUNNER) + +validate: + plutil -lint Info.plist FreeFlow.entitlements + @set -e; for script in $(SHELL_SCRIPTS); do bash -n "$$script"; done + @ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' $(YAML_FILES) icon: $(ICON_ICNS) diff --git a/Tests/AppContextServiceTests.swift b/Tests/AppContextServiceTests.swift index 39eb0a6a..cd6e2d1f 100644 --- a/Tests/AppContextServiceTests.swift +++ b/Tests/AppContextServiceTests.swift @@ -1,14 +1,12 @@ import Foundation -@main -struct AppContextServiceTests { - static func main() { +enum AppContextServiceTests { + static func run() { testQwenRawOutputIsSummarized() testQwenReasoningOutputIsStripped() testNonStrippingModelPreservesExistingBehavior() testDeprecatedGroqModelsAreNotPredefined() testQwenCleanupDisablesReasoning() - print("AppContextServiceTests passed") } private static func testQwenRawOutputIsSummarized() { @@ -18,7 +16,7 @@ struct AppContextServiceTests { let summary = AppContextService.activitySummary(from: output, model: "qwen/qwen3.6-27b") - expectEqual( + TestSupport.expectEqual( summary, "The user is replying to an email about the product launch. They likely intend to confirm the next steps." ) @@ -35,11 +33,11 @@ struct AppContextServiceTests { let summary = AppContextService.activitySummary(from: output, model: "qwen/qwen3.6-27b") - expectEqual( + TestSupport.expectEqual( summary, "The user is editing a project note in FreeFlow. They likely intend to tighten the release wording." ) - expect(summary?.contains("Hidden chain of thought") == false, "Qwen reasoning leaked into summary") + TestSupport.expect(summary?.contains("Hidden chain of thought") == false, "Qwen reasoning leaked into summary") } private static func testNonStrippingModelPreservesExistingBehavior() { @@ -50,7 +48,7 @@ struct AppContextServiceTests { model: "meta-llama/llama-4-scout-17b-16e-instruct" ) - expectEqual(summary, output) + TestSupport.expectEqual(summary, output) } private static func testDeprecatedGroqModelsAreNotPredefined() { @@ -62,25 +60,15 @@ struct AppContextServiceTests { ] for model in deprecatedModels { - expect(!ModelConfiguration.llmModels.contains(model), "Deprecated model remains in picker: \(model)") + TestSupport.expect(!ModelConfiguration.llmModels.contains(model), "Deprecated model remains in picker: \(model)") } - expect(ModelConfiguration.llmModels.contains("qwen/qwen3.6-27b"), "New fallback is missing from picker") + TestSupport.expect(ModelConfiguration.llmModels.contains("qwen/qwen3.6-27b"), "New fallback is missing from picker") } private static func testQwenCleanupDisablesReasoning() { let config = ModelConfiguration.config(for: "qwen/qwen3.6-27b") - expect(config.reasoningEffort == "none", "Qwen cleanup should disable reasoning") - expect(config.includeReasoning == false, "Qwen cleanup should exclude reasoning output") - } - - private static func expectEqual(_ actual: String?, _ expected: String, file: StaticString = #file, line: UInt = #line) { - expect(actual == expected, "Expected \(expected.debugDescription), got \((actual ?? "nil").debugDescription)", file: file, line: line) - } - - private static func expect(_ condition: Bool, _ message: String, file: StaticString = #file, line: UInt = #line) { - if !condition { - fatalError("\(file):\(line): \(message)") - } + TestSupport.expect(config.reasoningEffort == "none", "Qwen cleanup should disable reasoning") + TestSupport.expect(config.includeReasoning == false, "Qwen cleanup should exclude reasoning output") } } diff --git a/Tests/LLMCooldownManagerTests.swift b/Tests/LLMCooldownManagerTests.swift new file mode 100644 index 00000000..35930cde --- /dev/null +++ b/Tests/LLMCooldownManagerTests.swift @@ -0,0 +1,64 @@ +import Foundation + +enum LLMCooldownManagerTests { + static func run() { + testDailyQuotaTakesPriority() + testRetryAndTokenDurations() + testMalformedDurationsUseSafeFallback() + testPersistenceKeyIsStable() + } + + private static func testDailyQuotaTakesPriority() { + let result = cooldown(headers: [ + "x-ratelimit-remaining-requests": "0", + "x-ratelimit-reset-requests": "2m59.56s", + "retry-after": "2" + ]) + TestSupport.expectApproximatelyEqual(result.seconds, 179.56) + TestSupport.expectEqual(result.isDaily, true) + + let nonExhausted = cooldown(headers: [ + "x-ratelimit-remaining-requests": "1", + "x-ratelimit-reset-requests": "1h", + "retry-after": "7.66" + ]) + TestSupport.expectApproximatelyEqual(nonExhausted.seconds, 7.66) + TestSupport.expectEqual(nonExhausted.isDaily, false) + } + + private static func testRetryAndTokenDurations() { + TestSupport.expectApproximatelyEqual(cooldown(headers: ["retry-after": "120ms"]).seconds, 0.12) + TestSupport.expectApproximatelyEqual(cooldown(headers: ["retry-after": "1h2m3.5s"]).seconds, 3723.5) + TestSupport.expectApproximatelyEqual( + cooldown(headers: ["x-ratelimit-reset-tokens": "8.25s"]).seconds, + 8.25 + ) + } + + private static func testMalformedDurationsUseSafeFallback() { + for invalid in ["-3", "nan", "inf", "1d", "1h30", ""] { + let result = cooldown(headers: ["retry-after": invalid]) + TestSupport.expectApproximatelyEqual(result.seconds, 60) + TestSupport.expectEqual(result.isDaily, false) + } + } + + private static func testPersistenceKeyIsStable() { + TestSupport.expectEqual( + LLMCooldownManager.udKey(for: "openai/gpt-oss-20b"), + "llm_cooldown_expiry_openai/gpt-oss-20b" + ) + } + + private static func cooldown(headers: [String: String]) -> (seconds: TimeInterval, isDaily: Bool) { + guard let response = HTTPURLResponse( + url: URL(string: "https://api.groq.com/openai/v1/chat/completions")!, + statusCode: 429, + httpVersion: nil, + headerFields: headers + ) else { + fatalError("Could not create test HTTP response") + } + return LLMCooldownManager.rateLimitCooldown(from: response) + } +} diff --git a/Tests/ModelConfigurationTests.swift b/Tests/ModelConfigurationTests.swift new file mode 100644 index 00000000..15c4ff51 --- /dev/null +++ b/Tests/ModelConfigurationTests.swift @@ -0,0 +1,72 @@ +import Foundation + +enum ModelConfigurationTests { + static func run() { + testProviderlessAliasesMatchCanonicalModels() + testKnownModelSettingsRemainStable() + testModelListsAreConsistent() + testThinkTagStripping() + } + + private static func testProviderlessAliasesMatchCanonicalModels() { + assertSameConfig(" GPT-OSS-20B ", "openai/gpt-oss-20b") + assertSameConfig("gpt-oss-120b", "openai/gpt-oss-120b") + assertSameConfig("gpt-oss-safeguard-20b", "openai/gpt-oss-safeguard-20b") + assertSameConfig("qwen3-32b", "qwen/qwen3-32b") + assertSameConfig(" QWEN3.6-27B ", "qwen/qwen3.6-27b") + } + + private static func testKnownModelSettingsRemainStable() { + let gptOSS = ModelConfiguration.config(for: "openai/gpt-oss-20b") + TestSupport.expectEqual(gptOSS.maxCompletionTokens, 4096) + TestSupport.expectEqual(gptOSS.reasoningEffort, "low") + TestSupport.expectEqual(gptOSS.includeReasoning, false) + TestSupport.expectEqual(gptOSS.shouldStripThinkTags, false) + + let qwen = ModelConfiguration.config(for: "qwen/qwen3.6-27b") + TestSupport.expectEqual(qwen.reasoningEffort, "none") + TestSupport.expectEqual(qwen.includeReasoning, false) + TestSupport.expectEqual(qwen.shouldStripThinkTags, true) + + let unknown = ModelConfiguration.config(for: "example/unknown-model") + TestSupport.expectEqual(unknown.maxCompletionTokens, nil) + TestSupport.expectEqual(unknown.reasoningEffort, nil) + TestSupport.expectEqual(unknown.includeReasoning, nil) + TestSupport.expectEqual(unknown.shouldStripThinkTags, false) + } + + private static func testModelListsAreConsistent() { + TestSupport.expectEqual(Set(ModelConfiguration.llmModels).count, ModelConfiguration.llmModels.count) + TestSupport.expectEqual(Set(ModelConfiguration.visionModels).count, ModelConfiguration.visionModels.count) + TestSupport.expectEqual(Set(ModelConfiguration.transcriptionModels).count, ModelConfiguration.transcriptionModels.count) + TestSupport.expect( + Set(ModelConfiguration.visionModels).isSubset(of: Set(ModelConfiguration.llmModels)), + "Every vision model must also be selectable as an LLM" + ) + } + + private static func testThinkTagStripping() { + TestSupport.expectEqual( + ModelConfiguration.stripThinkTags("hidden Visible output"), + "Visible output" + ) + TestSupport.expectEqual( + ModelConfiguration.stripThinkTags("one\ntwo\nResult"), + "Result" + ) + TestSupport.expectEqual(ModelConfiguration.stripThinkTags("unfinished"), "") + TestSupport.expectEqual( + ModelConfiguration.stripThinkTags("Ordinary output with a later marker"), + "Ordinary output with a later marker" + ) + } + + private static func assertSameConfig(_ alias: String, _ canonical: String) { + let aliasConfig = ModelConfiguration.config(for: alias) + let canonicalConfig = ModelConfiguration.config(for: canonical) + TestSupport.expectEqual(aliasConfig.maxCompletionTokens, canonicalConfig.maxCompletionTokens) + TestSupport.expectEqual(aliasConfig.reasoningEffort, canonicalConfig.reasoningEffort) + TestSupport.expectEqual(aliasConfig.includeReasoning, canonicalConfig.includeReasoning) + TestSupport.expectEqual(aliasConfig.shouldStripThinkTags, canonicalConfig.shouldStripThinkTags) + } +} diff --git a/Tests/SemanticVersionTests.swift b/Tests/SemanticVersionTests.swift new file mode 100644 index 00000000..8697eeb8 --- /dev/null +++ b/Tests/SemanticVersionTests.swift @@ -0,0 +1,58 @@ +import Foundation + +enum SemanticVersionTests { + static func run() { + testParsingAndBuildMetadata() + testCoreVersionOrdering() + testOfficialPrereleaseOrdering() + testInvalidVersionsAreRejected() + } + + private static func testCoreVersionOrdering() { + TestSupport.expect(version("1.2.3") < version("1.2.4"), "Patch versions should order numerically") + TestSupport.expect(version("1.2.9") < version("1.3.0"), "Minor versions should order numerically") + TestSupport.expect(version("1.9.9") < version("2.0.0"), "Major versions should order numerically") + } + + private static func testParsingAndBuildMetadata() { + TestSupport.expectEqual(version(" v1.2.3 "), version("V1.2.3")) + TestSupport.expectEqual(version("1.2.3+build.1"), version("1.2.3+build.2")) + TestSupport.expect(version("1.2.3-alpha") < version("1.2.3"), "Prereleases must sort before stable releases") + TestSupport.expect(version("1.2.3-1") < version("1.2.3-alpha"), "Numeric identifiers must sort before alphanumeric identifiers") + TestSupport.expect(version("1.2.3-alpha") < version("1.2.3-alpha.1"), "A shorter matching prerelease must sort first") + } + + private static func testOfficialPrereleaseOrdering() { + let ordered = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0" + ].map(version) + + for index in 0..<(ordered.count - 1) { + TestSupport.expect( + ordered[index] < ordered[index + 1], + "Expected semantic version at index \(index) to sort before the next version" + ) + } + } + + private static func testInvalidVersionsAreRejected() { + let invalid = ["", "1.2", "1.2.3.4", "one.2.3", "1.2.3-", "1.2.3-alpha..1"] + for value in invalid { + TestSupport.expectEqual(SemanticVersion(value), nil) + } + } + + private static func version(_ value: String) -> SemanticVersion { + guard let parsed = SemanticVersion(value) else { + fatalError("Expected valid semantic version: \(value)") + } + return parsed + } +} diff --git a/Tests/ShortcutCoreTests.swift b/Tests/ShortcutCoreTests.swift new file mode 100644 index 00000000..17ca65fb --- /dev/null +++ b/Tests/ShortcutCoreTests.swift @@ -0,0 +1,347 @@ +import Foundation + +enum ShortcutCoreTests { + static func run() { + testBareFnHoldLifecycle() + testDefaultShortcutSpecificityOrdering() + testRightOptionPresetIsSideSpecific() + testExactModifierMatching() + testReducerHonorsExactModifierMatching() + testRepeatedKeyDownDoesNotReactivate() + testPasteAgainFiresOnLeadingEdgeOnly() + testBackendResetClearsActiveBindings() + testBindingMigrationAndIdentity() + testConflictDetection() + testHoldSessionControllerLifecycle() + testToggleSessionControllerLifecycle() + testHoldToToggleSessionControllerLifecycle() + } + + private static func testBareFnHoldLifecycle() { + let configuration = ShortcutConfiguration(hold: .defaultHold, toggle: .disabled) + let down = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .modifierChanged(keyCode: 63, isDown: true), + configuration: configuration + ) + let up = ShortcutMatcher.reduce( + state: down.state, + event: .modifierChanged(keyCode: 63, isDown: false), + configuration: configuration + ) + + TestSupport.expectEqual(down.emittedEvents, [.holdActivated]) + TestSupport.expectEqual(down.consumeDecision, .consume) + TestSupport.expectEqual(up.emittedEvents, [.holdDeactivated]) + TestSupport.expectEqual(up.consumeDecision, .consume) + } + + private static func testDefaultShortcutSpecificityOrdering() { + let configuration = ShortcutConfiguration( + hold: .defaultHold, + toggle: .defaultToggle + ) + let commandDown = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .modifierChanged(keyCode: 55, isDown: true), + configuration: configuration + ) + let fnDown = ShortcutMatcher.reduce( + state: commandDown.state, + event: .modifierChanged(keyCode: 63, isDown: true), + configuration: configuration + ) + let fnUp = ShortcutMatcher.reduce( + state: fnDown.state, + event: .modifierChanged(keyCode: 63, isDown: false), + configuration: configuration + ) + + TestSupport.expectEqual(fnDown.emittedEvents, [.toggleActivated, .holdActivated]) + TestSupport.expectEqual(fnUp.emittedEvents, [.holdDeactivated, .toggleDeactivated]) + } + + private static func testRightOptionPresetIsSideSpecific() { + let configuration = ShortcutConfiguration( + hold: ShortcutPreset.rightOption.binding, + toggle: .disabled + ) + let leftOption = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .modifierChanged(keyCode: 58, isDown: true), + configuration: configuration + ) + let rightOption = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .modifierChanged(keyCode: 61, isDown: true), + configuration: configuration + ) + + TestSupport.expectEqual(leftOption.emittedEvents, []) + TestSupport.expectEqual(rightOption.emittedEvents, [.holdActivated]) + } + + private static func testExactModifierMatching() { + TestSupport.expect( + ShortcutBinding.exactModifierKeyCodesMatch([54], exactModifierKeyCodes: [54, 55]), + "A generic Command binding should accept Right Command" + ) + TestSupport.expect( + ShortcutBinding.exactModifierKeyCodesMatch([55], exactModifierKeyCodes: [54, 55]), + "A generic Command binding should accept Left Command" + ) + TestSupport.expect( + !ShortcutBinding.exactModifierKeyCodesMatch([55, 56], exactModifierKeyCodes: [55]), + "Unexpected Shift should invalidate an exact Command binding" + ) + TestSupport.expect( + ShortcutBinding.exactModifierKeyCodesMatch( + [55, 56], + exactModifierKeyCodes: [55], + permittedAdditionalExactMatchModifiers: [.shift] + ), + "Explicitly permitted Shift should not invalidate an exact Command binding" + ) + } + + private static func testReducerHonorsExactModifierMatching() { + let binding = ShortcutBinding( + keyCode: 96, + keyDisplay: "F5", + modifiers: [.command], + kind: .key, + preset: nil, + exactModifierKeyCodes: [55] + ) + + let rightCommandState = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .modifierChanged(keyCode: 54, isDown: true), + configuration: ShortcutConfiguration(hold: binding, toggle: .disabled) + ).state + let rightCommandKey = ShortcutMatcher.reduce( + state: rightCommandState, + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: false), + configuration: ShortcutConfiguration(hold: binding, toggle: .disabled) + ) + TestSupport.expectEqual(rightCommandKey.emittedEvents, []) + + let leftCommandState = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .modifierChanged(keyCode: 55, isDown: true), + configuration: ShortcutConfiguration(hold: binding, toggle: .disabled) + ).state + let leftCommandKey = ShortcutMatcher.reduce( + state: leftCommandState, + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: false), + configuration: ShortcutConfiguration(hold: binding, toggle: .disabled) + ) + TestSupport.expectEqual(leftCommandKey.emittedEvents, [.holdActivated]) + + let shiftedState = ShortcutMatcher.reduce( + state: leftCommandState, + event: .modifierChanged(keyCode: 56, isDown: true), + configuration: ShortcutConfiguration(hold: binding, toggle: .disabled) + ).state + let shiftedKey = ShortcutMatcher.reduce( + state: shiftedState, + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: false), + configuration: ShortcutConfiguration(hold: binding, toggle: .disabled) + ) + TestSupport.expectEqual(shiftedKey.emittedEvents, []) + + let permittedConfiguration = ShortcutConfiguration( + hold: binding, + toggle: .disabled, + permittedAdditionalExactMatchModifiers: [.shift] + ) + let permittedKey = ShortcutMatcher.reduce( + state: shiftedState, + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: false), + configuration: permittedConfiguration + ) + TestSupport.expectEqual(permittedKey.emittedEvents, [.holdActivated]) + } + + private static func testRepeatedKeyDownDoesNotReactivate() { + let binding = ShortcutBinding( + keyCode: 96, + keyDisplay: "F5", + modifiers: [], + kind: .key, + preset: nil + ) + let configuration = ShortcutConfiguration(hold: binding, toggle: .disabled) + let first = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: false), + configuration: configuration + ) + let repeated = ShortcutMatcher.reduce( + state: first.state, + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: true), + configuration: configuration + ) + + TestSupport.expectEqual(first.emittedEvents, [.holdActivated]) + TestSupport.expectEqual(repeated.emittedEvents, []) + TestSupport.expectEqual(repeated.state, first.state) + TestSupport.expectEqual(repeated.consumeDecision, .consume) + } + + private static func testPasteAgainFiresOnLeadingEdgeOnly() { + let binding = ShortcutBinding( + keyCode: 96, + keyDisplay: "F5", + modifiers: [], + kind: .key, + preset: nil + ) + let configuration = ShortcutConfiguration(hold: .disabled, toggle: .disabled, copyAgain: binding) + let firstDown = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: false), + configuration: configuration + ) + let repeated = ShortcutMatcher.reduce( + state: firstDown.state, + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: true), + configuration: configuration + ) + let up = ShortcutMatcher.reduce( + state: repeated.state, + event: .keyChanged(keyCode: 96, isDown: false, isRepeat: false), + configuration: configuration + ) + let secondDown = ShortcutMatcher.reduce( + state: up.state, + event: .keyChanged(keyCode: 96, isDown: true, isRepeat: false), + configuration: configuration + ) + + TestSupport.expectEqual(firstDown.emittedEvents, [.copyAgainTriggered]) + TestSupport.expectEqual(repeated.emittedEvents, []) + TestSupport.expectEqual(up.emittedEvents, []) + TestSupport.expectEqual(secondDown.emittedEvents, [.copyAgainTriggered]) + } + + private static func testBackendResetClearsActiveBindings() { + let configuration = ShortcutConfiguration(hold: .defaultHold, toggle: .defaultToggle) + let commandDown = ShortcutMatcher.reduce( + state: ShortcutInputState(), + event: .modifierChanged(keyCode: 55, isDown: true), + configuration: configuration + ) + let fnDown = ShortcutMatcher.reduce( + state: commandDown.state, + event: .modifierChanged(keyCode: 63, isDown: true), + configuration: configuration + ) + let reset = ShortcutMatcher.reduce( + state: fnDown.state, + event: .backendReset, + configuration: configuration + ) + + TestSupport.expectEqual(reset.emittedEvents, [.holdDeactivated, .toggleDeactivated]) + TestSupport.expectEqual(reset.consumeDecision, .passthrough) + TestSupport.expect(reset.state.pressedKeyCodes.isEmpty, "Backend reset should clear pressed keys") + TestSupport.expect(reset.state.pressedModifierKeyCodes.isEmpty, "Backend reset should clear modifiers") + TestSupport.expect(!reset.state.holdIsActive && !reset.state.toggleIsActive, "Backend reset should clear active bindings") + } + + private static func testBindingMigrationAndIdentity() { + let stored = ShortcutBinding( + keyCode: 96, + keyDisplay: "F5", + modifiers: [], + kind: .key, + preset: nil, + exactModifierKeyCodes: [999, 61] + ) + let normalized = stored.normalizedForStorageMigration() + TestSupport.expectEqual(normalized.exactModifierKeyCodes, [61]) + TestSupport.expectEqual(normalized.modifiers, [.option]) + + let first = ShortcutBinding( + keyCode: 96, + keyDisplay: "F5", + modifiers: [.command, .option], + kind: .key, + preset: nil, + exactModifierKeyCodes: [55, 58] + ) + let second = ShortcutBinding( + keyCode: 96, + keyDisplay: "F5", + modifiers: [.option, .command], + kind: .key, + preset: nil, + exactModifierKeyCodes: [58, 55] + ) + TestSupport.expectEqual(first.id, second.id) + } + + private static func testConflictDetection() { + let first = ShortcutBinding( + keyCode: 96, + keyDisplay: "F5", + modifiers: [.command], + kind: .key, + preset: nil + ) + let same = ShortcutBinding( + keyCode: 96, + keyDisplay: "F5", + modifiers: [.command], + kind: .key, + preset: nil + ) + let different = ShortcutBinding( + keyCode: 97, + keyDisplay: "F6", + modifiers: [.command], + kind: .key, + preset: nil + ) + + TestSupport.expect(first.conflicts(with: same), "Equivalent bindings should conflict") + TestSupport.expect(same.conflicts(with: first), "Conflict detection should be symmetric") + TestSupport.expect(!first.conflicts(with: different), "Different primary keys should not conflict") + TestSupport.expect(!first.conflicts(with: .disabled), "Disabled bindings should not conflict") + } + + private static func testHoldSessionControllerLifecycle() { + let controller = DictationShortcutSessionController() + TestSupport.expectEqual(controller.handle(event: .holdActivated, isTranscribing: true), nil) + TestSupport.expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.hold)) + TestSupport.expectEqual(controller.handle(event: .holdDeactivated, isTranscribing: false), .stop) + TestSupport.expectEqual(controller.activeMode, nil) + } + + private static func testToggleSessionControllerLifecycle() { + let controller = DictationShortcutSessionController() + TestSupport.expectEqual(controller.handle(event: .toggleActivated, isTranscribing: false), .start(.toggle)) + TestSupport.expectEqual(controller.handle(event: .toggleActivated, isTranscribing: false), nil) + TestSupport.expectEqual(controller.handle(event: .toggleDeactivated, isTranscribing: false), nil) + TestSupport.expectEqual(controller.toggleStopArmed, true) + TestSupport.expectEqual(controller.handle(event: .toggleActivated, isTranscribing: false), .stop) + TestSupport.expectEqual(controller.activeMode, nil) + } + + private static func testHoldToToggleSessionControllerLifecycle() { + let controller = DictationShortcutSessionController() + TestSupport.expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.hold)) + TestSupport.expectEqual(controller.handle(event: .toggleActivated, isTranscribing: false), .switchedToToggle) + TestSupport.expectEqual(controller.handle(event: .holdDeactivated, isTranscribing: false), nil) + TestSupport.expectEqual(controller.activeMode, .toggle) + TestSupport.expectEqual(controller.handle(event: .copyAgainTriggered, isTranscribing: false), nil) + controller.beginManual(mode: .hold) + TestSupport.expectEqual(controller.activeMode, .hold) + controller.forceToggleMode() + TestSupport.expectEqual(controller.activeMode, .toggle) + controller.reset() + TestSupport.expectEqual(controller.activeMode, nil) + TestSupport.expectEqual(controller.toggleStopArmed, false) + } +} diff --git a/Tests/TestMain.swift b/Tests/TestMain.swift new file mode 100644 index 00000000..b2bbb842 --- /dev/null +++ b/Tests/TestMain.swift @@ -0,0 +1,13 @@ +import Foundation + +@main +struct FreeFlowTests { + static func main() { + AppContextServiceTests.run() + ModelConfigurationTests.run() + ShortcutCoreTests.run() + SemanticVersionTests.run() + LLMCooldownManagerTests.run() + print("FreeFlowTests passed") + } +} diff --git a/Tests/TestSupport.swift b/Tests/TestSupport.swift new file mode 100644 index 00000000..c96b90e4 --- /dev/null +++ b/Tests/TestSupport.swift @@ -0,0 +1,45 @@ +import Foundation + +enum TestSupport { + static func expect( + _ condition: @autoclosure () -> Bool, + _ message: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard condition() else { + fatalError("\(file):\(line): \(message)") + } + } + + static func expectEqual( + _ actual: @autoclosure () -> T, + _ expected: @autoclosure () -> T, + file: StaticString = #filePath, + line: UInt = #line + ) { + let actualValue = actual() + let expectedValue = expected() + expect( + actualValue == expectedValue, + "Expected \(String(describing: expectedValue)), got \(String(describing: actualValue))", + file: file, + line: line + ) + } + + static func expectApproximatelyEqual( + _ actual: Double, + _ expected: Double, + accuracy: Double = 0.000_001, + file: StaticString = #filePath, + line: UInt = #line + ) { + expect( + abs(actual - expected) <= accuracy, + "Expected \(expected) ± \(accuracy), got \(actual)", + file: file, + line: line + ) + } +} From 57531bec8715c3d1c39de7acf4c249498de13d21 Mon Sep 17 00:00:00 2001 From: marcbodea Date: Tue, 18 Aug 2026 13:28:37 +0200 Subject: [PATCH 2/2] Address review feedback on templates --- .github/ISSUE_TEMPLATE/feature.yml | 4 +++- .github/pull_request_template.md | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 4171990d..3e0703ef 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -32,7 +32,9 @@ body: description: | Would this read, store, log, or transmit audio, transcripts, screenshots, app context, selected text, clipboard data, diagnostics, - or identifiers? Which provider or local store would receive it? + or identifiers? Which provider or local store would receive it? Use + provider or store names only. Do not include private URLs or real user + content. validations: required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3f25b08a..3a775e35 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,7 +10,7 @@ - [ ] `make check` - [ ] `git diff --check` -- [ ] Focused regression test added or updated +- [ ] Focused regression test added or updated, or no-test reason documented below - [ ] Manual app verification completed, if required Manual verification performed: @@ -26,7 +26,7 @@ Manual verification performed: - [ ] macOS permissions and entitlements are unchanged, or explained below - [ ] Preferences and pipeline-history compatibility are unchanged, or explained below -- [ ] Signing, notarization, updates, and release workflows are unchanged, or +- [ ] Signing, notarization, updates, and GitHub workflows are unchanged, or explained below Risk notes: