From de9764d2768c022bc0a44482ff7300cdf6fbc6a0 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 30 Apr 2026 11:56:51 +0200 Subject: [PATCH] test: add SPM test harness covering Hotkey, ConfigFile, NudgeKind, EventStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipping binaries are still built by swiftc directly via build.sh. Package.swift exists only so `swift test` can run a unit-test suite over the testable parts of the panel — the apps don't depend on it. Test coverage - HotkeyTests — parse/encode for all modifier aliases, named keys, digits; empty / unknown-key error paths; multi-modifier combos; round-trip encode(parse(spec)) preserves canonical form - ConfigFileTests — comments, blank lines, quoted values, prefix-collision guards (STACKNUDGE_VOICE_NAME vs STACKNUDGE_VOICE), in-place replacement vs append, comment-line preservation, bool() truthy/falsy/default - NudgeKindTests — wire-format mapping, unknown-fallback semantics - EventStoreTests — append truncation at maxEvents, selection on append/remove, selectNext/Prev clamping, empty-store no-op Refactor - ConfigFile now exposes pure `parse(_:)` and `apply(_:key:value:)` helpers; read()/write() are thin disk wrappers. No call-site changes — every caller still uses read()/write()/bool()/path Build / CI - Package.swift declares one library target pointing at panel/ + shared/ with the existing layout (no source-tree restructuring). The build/ swiftc path keeps working in parallel - .github/workflows/ci.yml gains a `test-macos` job that runs `swift test` on macos-15 (Xcode preinstalled) - Makefile gets `make test`; prints a friendly Xcode-required message when XCTest isn't reachable (Command Line Tools-only installs) - .gitignore covers .build/ and Package.resolved - CONTRIBUTING.md documents the Xcode requirement for local testing Misc - Default voice changed from af_heart to af_aoede across Speaker.swift, PanelNav.swift, notify.sh, notify.conf.example Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 14 ++ .gitignore | 2 + CONTRIBUTING.md | 18 ++- Makefile | 15 +- Package.swift | 61 ++++++++ .../ConfigFileTests.swift | 147 ++++++++++++++++++ .../EventStoreTests.swift | 117 ++++++++++++++ .../HotkeyTests.swift | 121 ++++++++++++++ .../NudgeKindTests.swift | 24 +++ notify.conf.example | 4 +- notify.sh | 4 +- panel/MenuBar.swift | 38 +++-- panel/PanelNav.swift | 4 +- panel/Speaker.swift | 2 +- 14 files changed, 552 insertions(+), 19 deletions(-) create mode 100644 Package.swift create mode 100644 Tests/StackNudgePanelCoreTests/ConfigFileTests.swift create mode 100644 Tests/StackNudgePanelCoreTests/EventStoreTests.swift create mode 100644 Tests/StackNudgePanelCoreTests/HotkeyTests.swift create mode 100644 Tests/StackNudgePanelCoreTests/NudgeKindTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f18f328..53a5062 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,17 @@ jobs: - name: verify Info.plist is valid run: plutil -lint panel/Info.plist + + test-macos: + name: swift test + runs-on: macos-15 + steps: + - uses: actions/checkout@v6 + + - name: print toolchain + run: | + xcode-select -p + swift --version + + - name: swift test + run: swift test diff --git a/.gitignore b/.gitignore index 9c76aac..37f8c76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Build output build/ +.build/ +Package.resolved stack-nudge stack-nudge.app stack-nudge-panel.app diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f69d2bb..8d6d00e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,23 @@ make uninstall # remove apps, hooks, launchd agents, ~/.stack-nudge/ `make dev` is the main inner-loop tool. Leave it running in another terminal while you edit Swift files or `notify.sh` — the daemon bounces with the new build in ~2 seconds. -The Swift sources are compiled directly with `swiftc`. There is no Xcode project, no Swift Package Manager manifest, and no third-party Swift dependencies. The build system is intentionally minimal — see `build.sh`. +The Swift sources are compiled directly with `swiftc` for the shipping binaries — no Xcode project, no third-party Swift dependencies. There is a `Package.swift` manifest, but it exists only so `swift test` can run a unit-test suite over the testable parts of the panel; the apps themselves are still built by `build.sh`. + +## Tests + +```bash +make test # equivalent to `swift test` +``` + +Tests live in `Tests/StackNudgePanelCoreTests/` and cover the pure-logic surfaces: `Hotkey.parse` / `encode`, `ConfigFile.parse` / `apply` / `bool`, `NudgeKind`, and `EventStore`. + +`swift test` on macOS needs `XCTest`, which only ships with full Xcode. If you only have the Command Line Tools installed (`xcode-select -p` returns `/Library/Developer/CommandLineTools`), install Xcode and run: + +```bash +sudo xcode-select -s /Applications/Xcode.app/Contents/Developer +``` + +CI runs the suite on every push and PR — `swift test` is one of the required checks. ## Source layout diff --git a/Makefile b/Makefile index ee5f429..b21b5c7 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,8 @@ help: @echo " make uninstall remove app, hooks, launchd agents, ~/.stack-nudge/" @echo " make reload rebuild + replace installed app + bounce the daemon" @echo " make dev watch sources; auto-reload on change (ctrl-c to stop)" - @echo " make clean remove build/ output" + @echo " make test run swift test (needs full Xcode for XCTest)" + @echo " make clean remove build/ and .build/" .PHONY: build build: @@ -32,7 +33,17 @@ uninstall: .PHONY: clean clean: - @rm -rf build + @rm -rf build .build + +.PHONY: test +test: + @if ! xcrun --find xctest >/dev/null 2>&1; then \ + echo "swift test needs XCTest, which only ships with full Xcode."; \ + echo "Install Xcode and run:"; \ + echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; \ + exit 1; \ + fi + @swift test # One-shot dev cycle: rebuild, reinstall the app, refresh notify.sh in # ~/.stack-nudge so hook-side changes propagate, kickstart the daemon. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..bb5be86 --- /dev/null +++ b/Package.swift @@ -0,0 +1,61 @@ +// swift-tools-version:5.9 +// +// SPM manifest used ONLY for `swift test`. The shipping binaries are still +// built by `swiftc` directly via build.sh — no Xcode, no SPM dependencies. +// Keeping SPM here lets us run a `swift test` suite over the testable bits +// (Hotkey parsing, ConfigFile, NudgeKind, EventStore) without restructuring +// the repo into Sources//. + +import PackageDescription + +let package = Package( + name: "StackNudge", + platforms: [.macOS(.v13)], + products: [ + .library(name: "StackNudgePanelCore", targets: ["StackNudgePanelCore"]), + ], + targets: [ + .target( + name: "StackNudgePanelCore", + path: ".", + exclude: [ + // App entry points / resources are not library code. + "panel/main.swift", + "panel/Info.plist", + "notifier", + + // Top-level scripts, docs, and build artefacts. + "Makefile", + "build.sh", + "install.sh", + "uninstall.sh", + "notify.sh", + "notify.conf.example", + "README.md", + "LICENSE", + "NOTICE", + "PRIVACY.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + "CHANGELOG.md", + + // Directories not part of the testable surface. + "build", + "assets", + "phrases", + "hooks", + + // Tests own this directory; declare here so SPM doesn't + // sweep the test sources into the library target. + "Tests", + ], + sources: ["panel", "shared"] + ), + .testTarget( + name: "StackNudgePanelCoreTests", + dependencies: ["StackNudgePanelCore"], + path: "Tests/StackNudgePanelCoreTests" + ), + ] +) diff --git a/Tests/StackNudgePanelCoreTests/ConfigFileTests.swift b/Tests/StackNudgePanelCoreTests/ConfigFileTests.swift new file mode 100644 index 0000000..ba4b552 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/ConfigFileTests.swift @@ -0,0 +1,147 @@ +import XCTest + +@testable import StackNudgePanelCore + +final class ConfigFileTests: XCTestCase { + + // MARK: - parse + + func test_parse_readsKeyValuePairs() { + let map = ConfigFile.parse(""" + STACKNUDGE_PANEL=true + STACKNUDGE_VOICE=false + """) + XCTAssertEqual(map["STACKNUDGE_PANEL"], "true") + XCTAssertEqual(map["STACKNUDGE_VOICE"], "false") + } + + func test_parse_ignoresCommentsAndBlankLines() { + let map = ConfigFile.parse(""" + # leading comment + + STACKNUDGE_PANEL=true + # trailing comment + STACKNUDGE_VOICE=true + + """) + XCTAssertEqual(map.count, 2) + XCTAssertEqual(map["STACKNUDGE_PANEL"], "true") + XCTAssertEqual(map["STACKNUDGE_VOICE"], "true") + } + + func test_parse_stripsSurroundingQuotes() { + let map = ConfigFile.parse(""" + STACKNUDGE_VOICE_NAME="af_aoede" + STACKNUDGE_PANEL_HOTKEY='cmd+opt+n' + """) + XCTAssertEqual(map["STACKNUDGE_VOICE_NAME"], "af_aoede") + XCTAssertEqual(map["STACKNUDGE_PANEL_HOTKEY"], "cmd+opt+n") + } + + func test_parse_trimsWhitespaceAroundKeyAndValue() { + let map = ConfigFile.parse(" STACKNUDGE_VOICE = true ") + XCTAssertEqual(map["STACKNUDGE_VOICE"], "true") + } + + func test_parse_doesNotStripMismatchedQuotes() { + // Quote mismatch shouldn't be silently dropped. + let map = ConfigFile.parse(#"STACKNUDGE_VOICE_NAME="af_aoede'"#) + XCTAssertEqual(map["STACKNUDGE_VOICE_NAME"], #""af_aoede'"#) + } + + func test_parse_skipsLinesWithoutEqualsSign() { + let map = ConfigFile.parse(""" + STACKNUDGE_PANEL=true + not a key/value line + STACKNUDGE_VOICE=false + """) + XCTAssertEqual(map.count, 2) + } + + func test_parse_emptyInputYieldsEmptyMap() { + XCTAssertTrue(ConfigFile.parse("").isEmpty) + } + + func test_parse_lastValueWinsOnDuplicateKey() { + let map = ConfigFile.parse(""" + STACKNUDGE_VOICE=false + STACKNUDGE_VOICE=true + """) + XCTAssertEqual(map["STACKNUDGE_VOICE"], "true") + } + + // MARK: - bool + + func test_bool_recognisedTruthyValues() { + XCTAssertTrue(ConfigFile.bool(["k": "true"], "k", default: false)) + XCTAssertTrue(ConfigFile.bool(["k": "1"], "k", default: false)) + XCTAssertTrue(ConfigFile.bool(["k": "yes"], "k", default: false)) + } + + func test_bool_isCaseInsensitive() { + XCTAssertTrue(ConfigFile.bool(["k": "TRUE"], "k", default: false)) + XCTAssertTrue(ConfigFile.bool(["k": "Yes"], "k", default: false)) + } + + func test_bool_falsyValuesReturnFalseRegardlessOfDefault() { + XCTAssertFalse(ConfigFile.bool(["k": "false"], "k", default: true)) + XCTAssertFalse(ConfigFile.bool(["k": "no"], "k", default: true)) + XCTAssertFalse(ConfigFile.bool(["k": "0"], "k", default: true)) + } + + func test_bool_missingKeyUsesDefault() { + XCTAssertTrue(ConfigFile.bool([:], "k", default: true)) + XCTAssertFalse(ConfigFile.bool([:], "k", default: false)) + } + + // MARK: - apply + + func test_apply_replacesExistingKeyInPlace() { + let original = """ + # banner toggle + STACKNUDGE_BANNER=true + STACKNUDGE_VOICE=false + """ + let updated = ConfigFile.apply(original, key: "STACKNUDGE_BANNER", value: "false") + XCTAssertTrue(updated.contains("# banner toggle"), "preserved comments") + XCTAssertTrue(updated.contains("STACKNUDGE_BANNER=false")) + XCTAssertFalse(updated.contains("STACKNUDGE_BANNER=true")) + // No duplication. + XCTAssertEqual(updated.components(separatedBy: "STACKNUDGE_BANNER=").count - 1, 1) + } + + func test_apply_appendsKeyIfMissing() { + let original = "STACKNUDGE_PANEL=true\n" + let updated = ConfigFile.apply(original, key: "STACKNUDGE_VOICE", value: "true") + XCTAssertTrue(updated.contains("STACKNUDGE_PANEL=true")) + XCTAssertTrue(updated.contains("STACKNUDGE_VOICE=true")) + XCTAssertTrue(updated.hasSuffix("\n"), "preserves trailing newline") + } + + func test_apply_doesNotReplaceCommentedAssignment() { + let original = """ + # STACKNUDGE_VOICE=true + STACKNUDGE_PANEL=true + """ + let updated = ConfigFile.apply(original, key: "STACKNUDGE_VOICE", value: "true") + XCTAssertTrue(updated.contains("# STACKNUDGE_VOICE=true"), "commented line untouched") + XCTAssertTrue(updated.contains("STACKNUDGE_VOICE=true"), "real assignment appended") + } + + func test_apply_doesNotMatchOnPrefixCollision() { + // STACKNUDGE_VOICE_NAME starts with the same prefix as STACKNUDGE_VOICE + // — but the key match requires "=", so the longer key is safe. + let original = """ + STACKNUDGE_VOICE_NAME=af_aoede + """ + let updated = ConfigFile.apply(original, key: "STACKNUDGE_VOICE", value: "true") + XCTAssertTrue(updated.contains("STACKNUDGE_VOICE_NAME=af_aoede")) + XCTAssertTrue(updated.contains("STACKNUDGE_VOICE=true")) + } + + func test_apply_emptyContentsAppendsNewLineFile() { + let updated = ConfigFile.apply("", key: "STACKNUDGE_PANEL", value: "true") + XCTAssertTrue(updated.contains("STACKNUDGE_PANEL=true")) + XCTAssertTrue(updated.hasSuffix("\n")) + } +} diff --git a/Tests/StackNudgePanelCoreTests/EventStoreTests.swift b/Tests/StackNudgePanelCoreTests/EventStoreTests.swift new file mode 100644 index 0000000..bdb2b28 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/EventStoreTests.swift @@ -0,0 +1,117 @@ +import XCTest + +@testable import StackNudgePanelCore + +@MainActor +final class EventStoreTests: XCTestCase { + + private func makeEvent(message: String = "x") -> NudgeEvent { + NudgeEvent(agent: "claude-code", kind: .stop, + title: "Claude Code", message: message) + } + + func test_append_insertsAtFront() { + let store = EventStore() + let first = makeEvent(message: "first") + let second = makeEvent(message: "second") + store.append(first) + store.append(second) + XCTAssertEqual(store.events.map(\.message), ["second", "first"]) + } + + func test_append_setsSelectionToNewest() { + let store = EventStore() + let event = makeEvent() + store.append(event) + XCTAssertEqual(store.selectedID, event.id) + } + + func test_append_truncatesPastMaxEvents() { + // maxEvents is 5; we send 7. + let store = EventStore() + let events = (0..<7).map { makeEvent(message: "m\($0)") } + events.forEach(store.append) + XCTAssertEqual(store.events.count, 5) + // Newest 5 retained, oldest 2 evicted. + XCTAssertEqual(store.events.map(\.message), ["m6", "m5", "m4", "m3", "m2"]) + } + + func test_remove_dropsByIDAndReselects() { + let store = EventStore() + let a = makeEvent(message: "a") + let b = makeEvent(message: "b") + store.append(a) + store.append(b) + // selectedID is now b. + store.remove(id: b.id) + XCTAssertEqual(store.events.map(\.message), ["a"]) + XCTAssertEqual(store.selectedID, a.id, "selection moves to remaining event") + } + + func test_remove_nonSelectedLeavesSelectionAlone() { + let store = EventStore() + let a = makeEvent(message: "a") + let b = makeEvent(message: "b") + store.append(a) + store.append(b) + // selectedID is b. Remove a. + store.remove(id: a.id) + XCTAssertEqual(store.events.map(\.message), ["b"]) + XCTAssertEqual(store.selectedID, b.id) + } + + func test_remove_lastEventClearsSelection() { + let store = EventStore() + let event = makeEvent() + store.append(event) + store.remove(id: event.id) + XCTAssertTrue(store.events.isEmpty) + XCTAssertNil(store.selectedID) + } + + func test_selectNext_movesDownThenClampsAtBottom() { + let store = EventStore() + // Append in order so events == [c, b, a] (newest first). + let a = makeEvent(message: "a") + let b = makeEvent(message: "b") + let c = makeEvent(message: "c") + [a, b, c].forEach(store.append) + store.selectedID = c.id + store.selectNext() + XCTAssertEqual(store.selectedID, b.id) + store.selectNext() + XCTAssertEqual(store.selectedID, a.id) + store.selectNext() + XCTAssertEqual(store.selectedID, a.id, "clamps at last event") + } + + func test_selectPrevious_movesUpThenClampsAtTop() { + let store = EventStore() + let a = makeEvent(message: "a") + let b = makeEvent(message: "b") + let c = makeEvent(message: "c") + [a, b, c].forEach(store.append) + store.selectedID = a.id + store.selectPrevious() + XCTAssertEqual(store.selectedID, b.id) + store.selectPrevious() + XCTAssertEqual(store.selectedID, c.id) + store.selectPrevious() + XCTAssertEqual(store.selectedID, c.id, "clamps at first event") + } + + func test_selectNextAndPrevious_noOpOnEmptyStore() { + let store = EventStore() + store.selectNext() + store.selectPrevious() + XCTAssertNil(store.selectedID) + } + + func test_selectedEvent_matchesSelectedID() { + let store = EventStore() + let event = makeEvent(message: "selected") + store.append(event) + XCTAssertEqual(store.selectedEvent?.id, event.id) + XCTAssertEqual(store.selectedEvent?.message, "selected") + } +} diff --git a/Tests/StackNudgePanelCoreTests/HotkeyTests.swift b/Tests/StackNudgePanelCoreTests/HotkeyTests.swift new file mode 100644 index 0000000..d7b9bcf --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/HotkeyTests.swift @@ -0,0 +1,121 @@ +import Carbon.HIToolbox +import XCTest + +@testable import StackNudgePanelCore + +final class HotkeyTests: XCTestCase { + + // MARK: - parse + + func test_parse_singleModifierAndKey() throws { + let parsed = try XCTUnwrap(Hotkey.parse("cmd+n")) + XCTAssertEqual(parsed.modifiers, UInt32(cmdKey)) + XCTAssertEqual(parsed.keyCode, 45) + } + + func test_parse_isCaseInsensitive() { + let lower = Hotkey.parse("cmd+shift+n") + let upper = Hotkey.parse("CMD+SHIFT+N") + let mixed = Hotkey.parse("Cmd+Shift+N") + XCTAssertNotNil(lower) + XCTAssertEqual(lower?.modifiers, upper?.modifiers) + XCTAssertEqual(lower?.keyCode, upper?.keyCode) + XCTAssertEqual(lower?.modifiers, mixed?.modifiers) + XCTAssertEqual(lower?.keyCode, mixed?.keyCode) + } + + func test_parse_acceptsAllModifierAliases() throws { + // alt = opt, command = cmd, control = ctrl, option = opt + let opt = try XCTUnwrap(Hotkey.parse("opt+a")) + let alt = try XCTUnwrap(Hotkey.parse("alt+a")) + let option = try XCTUnwrap(Hotkey.parse("option+a")) + XCTAssertEqual(opt.modifiers, alt.modifiers) + XCTAssertEqual(opt.modifiers, option.modifiers) + + let cmd = try XCTUnwrap(Hotkey.parse("cmd+a")) + let command = try XCTUnwrap(Hotkey.parse("command+a")) + XCTAssertEqual(cmd.modifiers, command.modifiers) + + let ctrl = try XCTUnwrap(Hotkey.parse("ctrl+a")) + let control = try XCTUnwrap(Hotkey.parse("control+a")) + XCTAssertEqual(ctrl.modifiers, control.modifiers) + } + + func test_parse_combinesMultipleModifiers() throws { + let parsed = try XCTUnwrap(Hotkey.parse("cmd+shift+opt+ctrl+space")) + let expected = UInt32(cmdKey) | UInt32(shiftKey) | UInt32(optionKey) | UInt32(controlKey) + XCTAssertEqual(parsed.modifiers, expected) + XCTAssertEqual(parsed.keyCode, 49) // space + } + + func test_parse_supportsNamedKeys() throws { + XCTAssertEqual(try XCTUnwrap(Hotkey.parse("cmd+space")).keyCode, 49) + XCTAssertEqual(try XCTUnwrap(Hotkey.parse("cmd+return")).keyCode, 36) + XCTAssertEqual(try XCTUnwrap(Hotkey.parse("cmd+tab")).keyCode, 48) + XCTAssertEqual(try XCTUnwrap(Hotkey.parse("cmd+escape")).keyCode, 53) + } + + func test_parse_supportsDigitKeys() throws { + XCTAssertEqual(try XCTUnwrap(Hotkey.parse("cmd+1")).keyCode, 18) + XCTAssertEqual(try XCTUnwrap(Hotkey.parse("cmd+9")).keyCode, 25) + XCTAssertEqual(try XCTUnwrap(Hotkey.parse("cmd+0")).keyCode, 29) + } + + func test_parse_returnsNilOnEmpty() { + XCTAssertNil(Hotkey.parse("")) + } + + func test_parse_returnsNilOnUnknownKey() { + XCTAssertNil(Hotkey.parse("cmd+not-a-key")) + } + + func test_parse_returnsNilOnModifiersWithoutKey() { + // Only modifiers were named; no key resolved. + XCTAssertNil(Hotkey.parse("cmd+shift")) + } + + // MARK: - encode + + func test_encode_singleModifier() { + // 0x100000 = NSEvent.ModifierFlags.command.rawValue + let spec = Hotkey.encode(eventModifiers: 0x100000, keyCode: 45) + XCTAssertEqual(spec, "cmd+n") + } + + func test_encode_emitsModifiersInDocumentedOrder() { + // The implementation always emits in cmd, ctrl, opt, shift order so + // the output is deterministic regardless of the input bit pattern. + let all: UInt = 0x100000 | 0x040000 | 0x080000 | 0x020000 + let spec = Hotkey.encode(eventModifiers: all, keyCode: 45) + XCTAssertEqual(spec, "cmd+ctrl+opt+shift+n") + } + + func test_encode_returnsNilForUnknownKeyCode() { + XCTAssertNil(Hotkey.encode(eventModifiers: 0x100000, keyCode: 999)) + } + + // MARK: - round-trip + + func test_parse_then_encode_roundTrip() throws { + // For specs in canonical (cmd, ctrl, opt, shift, key) order, parse + + // encode should be lossless. + let specs = [ + "cmd+n", + "cmd+shift+n", + "cmd+opt+space", + "cmd+ctrl+opt+shift+n", + "shift+a", + ] + for spec in specs { + let parsed = try XCTUnwrap(Hotkey.parse(spec), "parse failed: \(spec)") + // Re-encode using NSEvent flag bits (the wire format encode expects). + var ns: UInt = 0 + if parsed.modifiers & UInt32(cmdKey) != 0 { ns |= 0x100000 } + if parsed.modifiers & UInt32(shiftKey) != 0 { ns |= 0x020000 } + if parsed.modifiers & UInt32(optionKey) != 0 { ns |= 0x080000 } + if parsed.modifiers & UInt32(controlKey) != 0 { ns |= 0x040000 } + let encoded = Hotkey.encode(eventModifiers: ns, keyCode: UInt16(parsed.keyCode)) + XCTAssertEqual(encoded, spec, "round-trip diverged for \(spec)") + } + } +} diff --git a/Tests/StackNudgePanelCoreTests/NudgeKindTests.swift b/Tests/StackNudgePanelCoreTests/NudgeKindTests.swift new file mode 100644 index 0000000..65219c7 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/NudgeKindTests.swift @@ -0,0 +1,24 @@ +import XCTest + +@testable import StackNudgePanelCore + +final class NudgeKindTests: XCTestCase { + + func test_rawWireValue_recognisedCases() { + XCTAssertEqual(NudgeKind(rawWireValue: "stop"), .stop) + XCTAssertEqual(NudgeKind(rawWireValue: "permission"), .permission) + } + + func test_rawWireValue_unknownFallsBackToOther() { + XCTAssertEqual(NudgeKind(rawWireValue: "garbage"), .other) + XCTAssertEqual(NudgeKind(rawWireValue: ""), .other) + // Case-sensitive — wire format is lowercase. + XCTAssertEqual(NudgeKind(rawWireValue: "Stop"), .other) + } + + func test_rawValue_matchesWireFormat() { + XCTAssertEqual(NudgeKind.stop.rawValue, "stop") + XCTAssertEqual(NudgeKind.permission.rawValue, "permission") + XCTAssertEqual(NudgeKind.other.rawValue, "other") + } +} diff --git a/notify.conf.example b/notify.conf.example index 70db96e..f4ff030 100644 --- a/notify.conf.example +++ b/notify.conf.example @@ -8,8 +8,8 @@ #STACKNUDGE_VOICE=true # StackVox voice ID to use. Run `~/.stack-nudge/venv/bin/stackvox voices` for the full list. -# Default: af_heart -#STACKNUDGE_VOICE_NAME=af_heart +# Default: af_aoede +#STACKNUDGE_VOICE_NAME=af_aoede # StackVox playback speed (1.0 = normal). # Default: 1.1 diff --git a/notify.sh b/notify.sh index eea0304..2a631a8 100755 --- a/notify.sh +++ b/notify.sh @@ -73,10 +73,10 @@ voice_permission_context() { # Set to "true" to speak notifications aloud via StackVox (offline TTS). # Requires: pip install stackvox && stackvox serve -# Optional: set STACKNUDGE_VOICE_NAME to a StackVox voice ID (default: af_heart) +# Optional: set STACKNUDGE_VOICE_NAME to a StackVox voice ID (default: af_aoede) # Optional: set STACKNUDGE_VOICE_SPEED to playback speed (default: 1.1) VOICE_ENABLED="${STACKNUDGE_VOICE:-false}" -VOICE_NAME="${STACKNUDGE_VOICE_NAME:-af_heart}" +VOICE_NAME="${STACKNUDGE_VOICE_NAME:-af_aoede}" VOICE_SPEED="${STACKNUDGE_VOICE_SPEED:-1.1}" # Map a Kokoro voice prefix to a phrase-file language code. diff --git a/panel/MenuBar.swift b/panel/MenuBar.swift index bc50a87..1df41ee 100644 --- a/panel/MenuBar.swift +++ b/panel/MenuBar.swift @@ -3,6 +3,10 @@ import AppKit // Read/write helper for ~/.stack-nudge/config. Preserves comments and // untouched lines on write — replaces only the line that sets the given key, // or appends if no uncommented line exists yet. +// +// Pure parsing/applying lives in `parse(_:)` and `apply(_:key:value:)` so +// tests can exercise them without touching disk; `read()` and `write(_:_:)` +// are thin wrappers that pin the on-disk path. enum ConfigFile { static let path = ("~/.stack-nudge/config" as NSString).expandingTildeInPath @@ -11,6 +15,25 @@ enum ConfigFile { guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { return [:] } + return parse(contents) + } + + static func write(key: String, value: String) { + let contents = (try? String(contentsOfFile: path, encoding: .utf8)) ?? "" + let updated = apply(contents, key: key, value: value) + try? updated.write(toFile: path, atomically: true, encoding: .utf8) + } + + static func bool(_ map: [String: String], _ key: String, default defaultValue: Bool) -> Bool { + guard let value = map[key]?.lowercased() else { return defaultValue } + return value == "true" || value == "1" || value == "yes" + } + + // MARK: - Pure helpers (testable) + + /// Parse a config-file string into a key→value map. Skips comments and + /// blank lines; strips surrounding single/double quotes from values. + static func parse(_ contents: String) -> [String: String] { var result: [String: String] = [:] for raw in contents.split(separator: "\n", omittingEmptySubsequences: false) { let line = raw.trimmingCharacters(in: .whitespaces) @@ -23,8 +46,11 @@ enum ConfigFile { return result } - static func write(key: String, value: String) { - let contents = (try? String(contentsOfFile: path, encoding: .utf8)) ?? "" + /// Return `contents` with `key` set to `value`. If an uncommented line + /// for the key exists, that line is replaced in place (preserving order + /// and surrounding comments). Otherwise the assignment is appended, + /// trailing blanks collapsed, and a final newline guaranteed. + static func apply(_ contents: String, key: String, value: String) -> String { var lines = contents.components(separatedBy: "\n") let newLine = "\(key)=\(value)" var replaced = false @@ -40,13 +66,7 @@ enum ConfigFile { lines.append(newLine) lines.append("") } - try? lines.joined(separator: "\n") - .write(toFile: path, atomically: true, encoding: .utf8) - } - - static func bool(_ map: [String: String], _ key: String, default defaultValue: Bool) -> Bool { - guard let value = map[key]?.lowercased() else { return defaultValue } - return value == "true" || value == "1" || value == "yes" + return lines.joined(separator: "\n") } private static func stripQuotes(_ value: String) -> String { diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 247578d..8253e3b 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -32,7 +32,7 @@ final class PanelNav: ObservableObject { @Published var voiceEnabled: Bool = false @Published var soundStop: String = "Glass" @Published var soundPermission: String = "Ping" - @Published var voice: String = "af_heart" + @Published var voice: String = "af_aoede" @Published var voiceSpeed: Double = 1.1 @Published var voicesAvailable: [String] = [] @Published var voicesLoading: Bool = true @@ -92,7 +92,7 @@ final class PanelNav: ObservableObject { voiceEnabled = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) soundStop = config["STACKNUDGE_SOUND_STOP"] ?? "Glass" soundPermission = config["STACKNUDGE_SOUND_PERMISSION"] ?? "Ping" - voice = config["STACKNUDGE_VOICE_NAME"] ?? "af_heart" + voice = config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede" voiceSpeed = Double(config["STACKNUDGE_VOICE_SPEED"] ?? "") ?? 1.1 } diff --git a/panel/Speaker.swift b/panel/Speaker.swift index 7516366..49c642a 100644 --- a/panel/Speaker.swift +++ b/panel/Speaker.swift @@ -21,7 +21,7 @@ enum Speaker { } let config = ConfigFile.read() - let resolvedVoice = voice ?? config["STACKNUDGE_VOICE_NAME"] ?? "af_heart" + let resolvedVoice = voice ?? config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede" let resolvedSpeed = speed ?? config["STACKNUDGE_VOICE_SPEED"] ?? "1.1" let say = Process() say.executableURL = URL(fileURLWithPath: stackvoxSay)