Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Build output
build/
.build/
Package.resolved
stack-nudge
stack-nudge.app
stack-nudge-panel.app
Expand Down
18 changes: 17 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 13 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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/<TargetName>/.

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"
),
]
)
147 changes: 147 additions & 0 deletions Tests/StackNudgePanelCoreTests/ConfigFileTests.swift
Original file line number Diff line number Diff line change
@@ -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 "<key>=", 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"))
}
}
Loading