From b9bd9c2cf5778dd4c6ea705de683b01c01f6de49 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Wed, 20 May 2026 11:45:20 +0200 Subject: [PATCH 1/6] feat: update character size range and default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove sizes with width < 8px (dropped 4×8 through 7×13) - add sizes up to 24px wide (added 19×36 through 24×46) - change default from 11×21 to 16×30 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MatrixScreenSaver/MatrixScreenSaverOptions.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index f6f1b0e..7e65aef 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -25,8 +25,8 @@ struct MatrixScreenSaverOptions: Equatable { static let defaultNumberSceneEnabled = true static let defaultTwinkleEnabled = true static let defaultDiffuseEnabled = true - static let defaultCharacterWidth = 11 - static let defaultCharacterHeight = 21 + static let defaultCharacterWidth = 16 + static let defaultCharacterHeight = 30 static let defaultRainDensity = 1.0 static let defaultFrameRate = 25.0 static let defaultErrorRate = 1.0 @@ -51,12 +51,12 @@ struct MatrixScreenSaverOptions: Equatable { static let numberSceneDescription = "Show the startup number scene before continuous rain. Turned on by default." static let diffuseDescription = "Turn on/off the glow effect. Turned on by default." static let twinkleDescription = "Turn on/off the twinkling effect. Turned on by default." - static let characterSizeDescription = "Character cell size in pixels. The default is 11×21." + static let characterSizeDescription = "Character cell size in pixels. The default is 16×30." static let characterSizePairs: [(width: Int, height: Int)] = [ - (4, 8), (5, 9), (6, 11), (7, 13), (8, 15), (9, 17), (10, 19), (11, 21), (12, 23), (13, 24), (14, 26), (15, 28), (16, 30), (17, 32), (18, 34) + (8, 15), (9, 17), (10, 19), (11, 21), (12, 23), (13, 24), (14, 26), (15, 28), (16, 30), (17, 32), (18, 34), (19, 36), (20, 38), (21, 40), (22, 42), (23, 44), (24, 46) ] - static let defaultCharacterSizeIndex = 7 + static let defaultCharacterSizeIndex = 8 static let rainDensityDescription = "Set the factor for the density of rain drops. A positive number. The default is 1.0." static let frameRateDescription = "Set the frame rate per second. A positive number less than or equal to 1000. The default is 25." static let errorRateDescription = "Set the factor for the rate of character changes. A non-negative number. The default is 1.0." From 82c87873e0f0afa49a233e1825c45a992811fb52 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Wed, 20 May 2026 12:50:53 +0200 Subject: [PATCH 2/6] Potential fix for code scanning alert no. 1: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/branch-policy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml index 3d3f0d4..f1ad3cf 100644 --- a/.github/workflows/branch-policy.yml +++ b/.github/workflows/branch-policy.yml @@ -1,5 +1,8 @@ name: Branch policy +permissions: + contents: read + on: pull_request: branches: From 73fa914afd6d83d7a9c77ed88cd2f5bd5b91c279 Mon Sep 17 00:00:00 2001 From: Jonathan Mao Date: Tue, 19 May 2026 18:53:01 +1000 Subject: [PATCH 3/6] Add per-display seed offset for independent multi-monitor animations Each physical display gets a unique animation because NativeMatrixRenderer now XORs a seedOffset (derived from CGDirectDisplayID via NSScreenNumber) into sceneSeed inside beginSceneSequence(). Previously all screens shared the same quantised-time seed, producing identical rain on every display. The macOS ScreenSaver framework already instantiates one ScreenSaverView per connected display; this change makes each instance animate independently. Includes TDD tests (tests.sh) covering seed determinism and per-display divergence. Co-Authored-By: Claude Sonnet 4.6 --- .../MatrixScreenSaverView.swift | 10 +++ .../NativeMatrixRenderer.swift | 6 +- Tests/MatrixScreenSaverTests/main.swift | 62 +++++++++++++++++++ tests.sh | 21 +++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 Tests/MatrixScreenSaverTests/main.swift create mode 100755 tests.sh diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index c853e9c..61b458f 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -78,6 +78,15 @@ final class MatrixScreenSaverView: ScreenSaverView { true } + /// A stable seed derived from this screen's CGDirectDisplayID. + /// Different physical displays return different values, giving each + /// NativeMatrixRenderer instance an independent animation sequence. + private var displaySeed: UInt64 { + let id = window?.screen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? UInt32 ?? 0 + return UInt64(id) + } + override var hasConfigureSheet: Bool { true } @@ -170,6 +179,7 @@ final class MatrixScreenSaverView: ScreenSaverView { appendDebugLog("[\(debugIdentifier)] startAnimation bounds=\(NSStringFromRect(bounds))") updateScreenSaverLifecycleObservation() updateLayout() + nativeRenderer.seedOffset = displaySeed nativeRenderer.start() } diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index e466ef3..294ab23 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -239,6 +239,10 @@ final class NativeMatrixRenderer { private(set) var rows = 0 private(set) var levelColors = NativeMatrixRenderer.palette + /// XORed into sceneSeed in beginSceneSequence so each physical display + /// gets an independent animation while preserving per-display determinism. + var seedOffset: UInt64 = 0 + private var configuration = Configuration() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool @@ -809,7 +813,7 @@ final class NativeMatrixRenderer { let syncWindow: TimeInterval = 10.0 sceneStartTime = floor(nowTime / syncWindow) * syncWindow + syncWindow } - sceneSeed = UInt64(bitPattern: Int64(sceneStartTime)) + sceneSeed = UInt64(bitPattern: Int64(sceneStartTime)) ^ seedOffset rainRNG = Xorshift64(seed: sceneSeed &+ 3) guard columns > 0, rows > 0 else { diff --git a/Tests/MatrixScreenSaverTests/main.swift b/Tests/MatrixScreenSaverTests/main.swift new file mode 100644 index 0000000..007453f --- /dev/null +++ b/Tests/MatrixScreenSaverTests/main.swift @@ -0,0 +1,62 @@ +import Foundation + +// Test runner — compiled as part of a multi-file swiftc invocation. +// This file is the sole top-level entry point (named main.swift by convention). + +var allPassed = true + +func check(_ condition: Bool, _ name: String, file: String = #file, line: Int = #line) { + if condition { + print("PASS: \(name)") + } else { + print("FAIL: \(name) [\(file):\(line)]") + allPassed = false + } +} + +func runTests() { + // T1: Xorshift64 with identical seeds produces identical sequences + var rng1 = Xorshift64(seed: 42) + var rng2 = Xorshift64(seed: 42) + let seq1 = (0..<10).map { _ in rng1.next() } + let seq2 = (0..<10).map { _ in rng2.next() } + check(seq1 == seq2, "T1: identical seeds produce identical sequences") + + // T2: Xorshift64 with different seeds produces different sequences + var rng3 = Xorshift64(seed: 42) + var rng4 = Xorshift64(seed: 99999) + let seq3 = (0..<10).map { _ in rng3.next() } + let seq4 = (0..<10).map { _ in rng4.next() } + check(seq3 != seq4, "T2: different seeds produce different sequences") + + // T3: NativeMatrixRenderer has a seedOffset property that defaults to 0 + let renderer = NativeMatrixRenderer() + check(renderer.seedOffset == 0, "T3: seedOffset defaults to 0") + + // T4: Two renderers with different seedOffsets diverge after equal frames + var r1 = NativeMatrixRenderer() + var r2 = NativeMatrixRenderer() + r1.seedOffset = 0 + r2.seedOffset = UInt64(0xDEAD_BEEF) + var cfg = NativeMatrixRenderer.Configuration() + cfg.neoMessageSceneEnabled = false + cfg.numberSceneEnabled = false + r1.resize(to: TerminalSize(columns: 40, rows: 20)) + r2.resize(to: TerminalSize(columns: 40, rows: 20)) + r1.updateConfiguration(cfg) + r2.updateConfiguration(cfg) + r1.start() + r2.start() + for _ in 0..<60 { r1.advance(); r2.advance() } + var differ = false + outer: for row in 0..<20 { + for col in 0..<40 { + if r1[row, col] != r2[row, col] { differ = true; break outer } + } + } + check(differ, "T4: renderers with different seedOffsets diverge after 60 frames") +} + +runTests() +print(allPassed ? "\nAll tests passed." : "\nSome tests FAILED.") +exit(allPassed ? 0 : 1) diff --git a/tests.sh b/tests.sh new file mode 100755 index 0000000..e6b2286 --- /dev/null +++ b/tests.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +SRC="$ROOT/Sources/MatrixScreenSaver" +TEST_BIN=/tmp/matrix_screensaver_tests + +swiftc \ + -framework Foundation \ + -framework AppKit \ + -framework ScreenSaver \ + "$SRC/TerminalSupport.swift" \ + "$SRC/MatrixScreenSaverOptions.swift" \ + "$SRC/Xorshift64.swift" \ + "$SRC/NeoMessageScene.swift" \ + "$SRC/NativeMatrixRenderer.swift" \ + "$SRC/MatrixScreenSaverView.swift" \ + "$ROOT/Tests/MatrixScreenSaverTests/main.swift" \ + -o "$TEST_BIN" + +"$TEST_BIN" From 8e969b3da8bc939aef27ebab7bfc34329e39fb3f Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Wed, 20 May 2026 13:36:41 +0200 Subject: [PATCH 4/6] docs: add Swift documentation conventions to AGENTS.md - document use of /// doc comments for types, properties, and methods - add Parameters, Returns, and Throws block guidelines - add MARK: - section separator guidance - note to comment why not what, skip self-evident code Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 630f01d..ee975a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,40 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature - Local preview host in `Tools/PreviewHost.swift` - Shell scripts for build/install/preview: `build.sh`, `install.sh`, `preview.sh` +## Code documentation + +Use Swift doc comments (`///`) for all public and internal declarations — types, properties, methods, and parameters. Follow the standard Swift/Xcode documentation style: + +```swift +/// A single falling column of Matrix characters. +/// +/// Each column tracks its own position, speed, and glyph sequence +/// so the renderer can update it independently each frame. +class MatrixColumn { + + /// The horizontal position of the column in grid units. + let x: Int + + /// Initializes a new column at the given grid position. + /// + /// - Parameters: + /// - x: Horizontal grid index for this column. + /// - speed: Fall speed in cells per frame. + init(x: Int, speed: Int) { … } + + /// Advances the column by one frame, updating character positions. + /// + /// - Returns: `true` if the column is still visible, `false` if it has scrolled off screen. + func tick() -> Bool { … } +} +``` + +- Use `///` (triple-slash) for doc comments; `//` for inline clarifications only. +- Include a `- Parameters:` block whenever a function takes non-obvious arguments. +- Include `- Returns:` and `- Throws:` blocks where applicable. +- Use `// MARK: -` to separate logical sections within a file. +- Do not comment self-evident code; focus comments on *why*, not *what*. + ## Implementation notes - Keep the renderer native; do not reintroduce an external terminal or runtime wrapper. From 608ac2d8705469625231348de268303effa084d6 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Wed, 20 May 2026 13:51:42 +0200 Subject: [PATCH 5/6] test: add TAP test suite and CI workflow - split tests into Xorshift64Tests.swift and NativeMatrixRendererTests.swift - rewrite test runner to emit TAP output (Test Anything Protocol) - extract MatrixRendererLimits.swift for Foundation-only validation constants - add .github/workflows/tests.yml to run tests on macOS CI - add Test section to README - update build.sh and tests.sh for new file layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 25 +++++++ README.md | 8 +++ .../MatrixRendererLimits.swift | 9 +++ .../MatrixScreenSaverOptions.swift | 10 +-- .../NativeMatrixRenderer.swift | 12 ++-- .../NativeMatrixRendererTests.swift | 21 ++++++ .../Xorshift64Tests.swift | 11 ++++ Tests/MatrixScreenSaverTests/main.swift | 66 ++++--------------- build.sh | 1 + tests.sh | 8 ++- 10 files changed, 104 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 Sources/MatrixScreenSaver/MatrixRendererLimits.swift create mode 100644 Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift create mode 100644 Tests/MatrixScreenSaverTests/Xorshift64Tests.swift diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..daca352 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +permissions: + contents: read + +on: + push: + branches: + - development + - 'feat/**' + - 'fix/**' + pull_request: + branches: + - development + - main + +jobs: + test: + name: Run tests + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Run tests + run: ./tests.sh diff --git a/README.md b/README.md index 982b13b..4422571 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,14 @@ build/MatrixScreenSaver.saver `build.sh` reads the current version from `./VERSION` and writes it into the saver bundle metadata. +### Test + +```bash +./tests.sh +``` + +Compiles and runs the test suite using `swiftc`. No Xcode required — Command Line Tools are sufficient. + ### Preview without installing ```bash diff --git a/Sources/MatrixScreenSaver/MatrixRendererLimits.swift b/Sources/MatrixScreenSaver/MatrixRendererLimits.swift new file mode 100644 index 0000000..3527f97 --- /dev/null +++ b/Sources/MatrixScreenSaver/MatrixRendererLimits.swift @@ -0,0 +1,9 @@ +/// Validation limits shared between NativeMatrixRenderer and MatrixScreenSaverOptions. +/// Foundation-only so this file can be compiled without AppKit or ScreenSaver.framework. +enum MatrixRendererLimits { + static let minimumRainDensity: Double = 0.0001 + static let minimumFrameRate: Double = 0.0001 + static let maximumFrameRate: Double = 1000.0 + static let minimumErrorRate: Double = 0.0 + static let minimumNeoMessageSpeedFactor: Double = 0.0001 +} diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index 7e65aef..6ad3293 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -34,11 +34,11 @@ struct MatrixScreenSaverOptions: Equatable { static let defaultScanLinesIntensity = 0.25 static let defaultScanLinesVertical = true - static let minimumRainDensity = 0.0001 - static let minimumFrameRate = 0.0001 - static let maximumFrameRate = 1000.0 - static let minimumErrorRate = 0.0 - static let minimumNeoMessageSpeedFactor = 0.0001 + static let minimumRainDensity = MatrixRendererLimits.minimumRainDensity + static let minimumFrameRate = MatrixRendererLimits.minimumFrameRate + static let maximumFrameRate = MatrixRendererLimits.maximumFrameRate + static let minimumErrorRate = MatrixRendererLimits.minimumErrorRate + static let minimumNeoMessageSpeedFactor = MatrixRendererLimits.minimumNeoMessageSpeedFactor static let minimumCharacterWidth = 1 static let minimumCharacterHeight = 1 static let minNeoMessageLineCount = 1 diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 294ab23..8a1c7bc 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -21,13 +21,13 @@ final class NativeMatrixRenderer { func sanitized() -> Configuration { Configuration( neoMessageSceneEnabled: neoMessageSceneEnabled, - neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixScreenSaverOptions.minimumNeoMessageSpeedFactor), + neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixRendererLimits.minimumNeoMessageSpeedFactor), numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, - rainDensity: max(rainDensity, MatrixScreenSaverOptions.minimumRainDensity), - frameRate: min(max(frameRate, MatrixScreenSaverOptions.minimumFrameRate), MatrixScreenSaverOptions.maximumFrameRate), - errorRate: max(errorRate, MatrixScreenSaverOptions.minimumErrorRate), + rainDensity: max(rainDensity, MatrixRendererLimits.minimumRainDensity), + frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate), + errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate), characters: characters, neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.defaultLines : neoMessageLines, skipSyncDelay: skipSyncDelay @@ -344,7 +344,7 @@ final class NativeMatrixRenderer { } var preferredAnimationTimeInterval: TimeInterval { - 1.0 / max(configuration.frameRate, MatrixScreenSaverOptions.minimumFrameRate) + 1.0 / max(configuration.frameRate, MatrixRendererLimits.minimumFrameRate) } /// Starts the renderer state for a new saver session. @@ -851,7 +851,7 @@ final class NativeMatrixRenderer { } private var spawnModulo: Int { - let density = max(configuration.rainDensity, MatrixScreenSaverOptions.minimumRainDensity) + let density = max(configuration.rainDensity, MatrixRendererLimits.minimumRainDensity) let baseSpawnModulo = (150.0 / density) / Double(max(columns, 1)) return max(1, Int(ceil(baseSpawnModulo * frameRateScale))) } diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift new file mode 100644 index 0000000..894582f --- /dev/null +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -0,0 +1,21 @@ +func nativeMatrixRendererTests() { + ok(NativeMatrixRenderer().seedOffset == 0, + "NativeMatrixRenderer: seedOffset defaults to 0") + + let r1: NativeMatrixRenderer = .init() + let r2: NativeMatrixRenderer = .init() + r1.seedOffset = 0 + r2.seedOffset = 0xDEAD_BEEF + var cfg = NativeMatrixRenderer.Configuration() + cfg.neoMessageSceneEnabled = false + cfg.numberSceneEnabled = false + r1.resize(to: TerminalSize(columns: 40, rows: 20)) + r2.resize(to: TerminalSize(columns: 40, rows: 20)) + r1.updateConfiguration(cfg) + r2.updateConfiguration(cfg) + r1.start() + r2.start() + for _ in 0..<60 { r1.advance(); r2.advance() } + let differ = (0..<20).contains { row in (0..<40).contains { col in r1[row, col] != r2[row, col] } } + ok(differ, "NativeMatrixRenderer: renderers with different seedOffsets diverge after 60 frames") +} diff --git a/Tests/MatrixScreenSaverTests/Xorshift64Tests.swift b/Tests/MatrixScreenSaverTests/Xorshift64Tests.swift new file mode 100644 index 0000000..b6e93ec --- /dev/null +++ b/Tests/MatrixScreenSaverTests/Xorshift64Tests.swift @@ -0,0 +1,11 @@ +func xorshift64Tests() { + var rng1 = Xorshift64(seed: 42) + var rng2 = Xorshift64(seed: 42) + ok((0..<10).map { _ in rng1.next() } == (0..<10).map { _ in rng2.next() }, + "Xorshift64: identical seeds produce identical sequences") + + var rng3 = Xorshift64(seed: 42) + var rng4 = Xorshift64(seed: 99999) + ok((0..<10).map { _ in rng3.next() } != (0..<10).map { _ in rng4.next() }, + "Xorshift64: different seeds produce different sequences") +} diff --git a/Tests/MatrixScreenSaverTests/main.swift b/Tests/MatrixScreenSaverTests/main.swift index 007453f..6bf26a9 100644 --- a/Tests/MatrixScreenSaverTests/main.swift +++ b/Tests/MatrixScreenSaverTests/main.swift @@ -1,62 +1,22 @@ import Foundation -// Test runner — compiled as part of a multi-file swiftc invocation. -// This file is the sole top-level entry point (named main.swift by convention). +// TAP harness — shared state used by all test files. +var testCount = 0 +var failCount = 0 -var allPassed = true - -func check(_ condition: Bool, _ name: String, file: String = #file, line: Int = #line) { +func ok(_ condition: Bool, _ description: String, file: StaticString = #file, line: Int = #line) { + testCount += 1 if condition { - print("PASS: \(name)") + print("ok \(testCount) - \(description)") } else { - print("FAIL: \(name) [\(file):\(line)]") - allPassed = false + print("not ok \(testCount) - \(description)") + print(" # \(file):\(line)") + failCount += 1 } } -func runTests() { - // T1: Xorshift64 with identical seeds produces identical sequences - var rng1 = Xorshift64(seed: 42) - var rng2 = Xorshift64(seed: 42) - let seq1 = (0..<10).map { _ in rng1.next() } - let seq2 = (0..<10).map { _ in rng2.next() } - check(seq1 == seq2, "T1: identical seeds produce identical sequences") - - // T2: Xorshift64 with different seeds produces different sequences - var rng3 = Xorshift64(seed: 42) - var rng4 = Xorshift64(seed: 99999) - let seq3 = (0..<10).map { _ in rng3.next() } - let seq4 = (0..<10).map { _ in rng4.next() } - check(seq3 != seq4, "T2: different seeds produce different sequences") - - // T3: NativeMatrixRenderer has a seedOffset property that defaults to 0 - let renderer = NativeMatrixRenderer() - check(renderer.seedOffset == 0, "T3: seedOffset defaults to 0") - - // T4: Two renderers with different seedOffsets diverge after equal frames - var r1 = NativeMatrixRenderer() - var r2 = NativeMatrixRenderer() - r1.seedOffset = 0 - r2.seedOffset = UInt64(0xDEAD_BEEF) - var cfg = NativeMatrixRenderer.Configuration() - cfg.neoMessageSceneEnabled = false - cfg.numberSceneEnabled = false - r1.resize(to: TerminalSize(columns: 40, rows: 20)) - r2.resize(to: TerminalSize(columns: 40, rows: 20)) - r1.updateConfiguration(cfg) - r2.updateConfiguration(cfg) - r1.start() - r2.start() - for _ in 0..<60 { r1.advance(); r2.advance() } - var differ = false - outer: for row in 0..<20 { - for col in 0..<40 { - if r1[row, col] != r2[row, col] { differ = true; break outer } - } - } - check(differ, "T4: renderers with different seedOffsets diverge after 60 frames") -} +xorshift64Tests() +nativeMatrixRendererTests() -runTests() -print(allPassed ? "\nAll tests passed." : "\nSome tests FAILED.") -exit(allPassed ? 0 : 1) +print("1..\(testCount)") +exit(failCount == 0 ? 0 : 1) diff --git a/build.sh b/build.sh index aafd61c..e6c6e30 100755 --- a/build.sh +++ b/build.sh @@ -36,6 +36,7 @@ swiftc \ "$SOURCE_DIR/MatrixScreenSaverOptions.swift" \ "$SOURCE_DIR/Xorshift64.swift" \ "$SOURCE_DIR/NeoMessageScene.swift" \ + "$SOURCE_DIR/MatrixRendererLimits.swift" \ "$SOURCE_DIR/NativeMatrixRenderer.swift" \ "$SOURCE_DIR/MatrixScreenSaverView.swift" diff --git a/tests.sh b/tests.sh index e6b2286..f725aca 100755 --- a/tests.sh +++ b/tests.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" SRC="$ROOT/Sources/MatrixScreenSaver" +TESTS="$ROOT/Tests/MatrixScreenSaverTests" TEST_BIN=/tmp/matrix_screensaver_tests swiftc \ @@ -10,12 +11,13 @@ swiftc \ -framework AppKit \ -framework ScreenSaver \ "$SRC/TerminalSupport.swift" \ - "$SRC/MatrixScreenSaverOptions.swift" \ "$SRC/Xorshift64.swift" \ "$SRC/NeoMessageScene.swift" \ + "$SRC/MatrixRendererLimits.swift" \ "$SRC/NativeMatrixRenderer.swift" \ - "$SRC/MatrixScreenSaverView.swift" \ - "$ROOT/Tests/MatrixScreenSaverTests/main.swift" \ + "$TESTS/Xorshift64Tests.swift" \ + "$TESTS/NativeMatrixRendererTests.swift" \ + "$TESTS/main.swift" \ -o "$TEST_BIN" "$TEST_BIN" From f98b9c57750b39abd0723189e87e1ac45120f30d Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Wed, 20 May 2026 13:56:35 +0200 Subject: [PATCH 6/6] docs: update AGENTS.md with full project layout and conventions - add repository layout tables for source, tests, scripts, workflows - document TAP test format and how to add new testable source files - add Build and Tests sections with commands - note MatrixRendererLimits Foundation-only constraint - add development as default PR branch - include test: conventional commit type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee975a5..6802365 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,79 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature - `ScreenSaverDefaults` for persisted options - Native renderer in `Sources/MatrixScreenSaver/` - Local preview host in `Tools/PreviewHost.swift` -- Shell scripts for build/install/preview: `build.sh`, `install.sh`, `preview.sh` +- Shell scripts for build/install/preview/test: `build.sh`, `install.sh`, `preview.sh`, `tests.sh` +- GitHub Actions for CI and releases + +## Repository layout + +### Source — `Sources/MatrixScreenSaver/` + +| File | Purpose | +|---|---| +| `MatrixScreenSaverView.swift` | Main `.saver` view — drawing, lifecycle, layout, options wiring | +| `NativeMatrixRenderer.swift` | Core in-process Matrix animation engine | +| `MatrixScreenSaverOptions.swift` | Persisted options model and native **Options…** configure sheet | +| `MatrixRendererLimits.swift` | Shared numeric clamps/limits (Foundation-only, no AppKit/ScreenSaver dependency) | +| `NeoMessageScene.swift` | "Neo" intro message state machine | +| `Xorshift64.swift` | Deterministic 64-bit RNG | +| `TerminalSupport.swift` | Shared terminal-like types: `TerminalSize`, `TerminalColor` | + +### Tools + +| File | Purpose | +|---|---| +| `Tools/PreviewHost.swift` | Standalone preview window host used by `./preview.sh` | + +### Tests — `Tests/MatrixScreenSaverTests/` + +| File | Purpose | +|---|---| +| `main.swift` | TAP harness entry point — `ok()` helper, orchestrates test files, prints plan | +| `Xorshift64Tests.swift` | Tests for `Xorshift64` RNG determinism | +| `NativeMatrixRendererTests.swift` | Tests for renderer `seedOffset` default and per-display divergence | + +### Scripts + +| Script | Purpose | +|---|---| +| `build.sh` | Builds `build/MatrixScreenSaver.saver` via `swiftc` | +| `tests.sh` | Compiles and runs the TAP test suite via `swiftc` (no Xcode required) | +| `install.sh` | Builds and installs to `~/Library/Screen Savers/`, kills `ScreenSaverEngine` | +| `preview.sh` | Builds and launches the saver in the preview host for fast iteration | +| `Scripts/install-saver.sh` | Shared installer (strips quarantine, copies with `ditto`, verifies hash) | + +### CI — `.github/workflows/` + +| Workflow | Trigger | Purpose | +|---|---|---| +| `tests.yml` | Push to `development`/`feat/**`/`fix/**`; PRs to `development` or `main` | Runs `./tests.sh` on `macos-latest` | +| `release.yml` | `workflow_dispatch` on `main`; push to `main` when `VERSION` changes | Bumps version, updates changelog, opens release PR; on merge publishes GitHub Release | + +### Resources + +- `Resources/Info.plist` — bundle metadata; version injected at build time from `VERSION` +- `Resources/Preview.png`, `Resources/Preview@2x.png` — saver thumbnail assets +- `VERSION` — single source of truth for the bundle and release version + +## Build + +```bash +./build.sh +``` + +Outputs `build/MatrixScreenSaver.saver`. Reads `VERSION`, compiles with `-O -parse-as-library -emit-library -Xlinker -bundle`, injects version into plist, ad-hoc codesigns. + +## Tests + +```bash +./tests.sh +``` + +Compiles a test binary with `swiftc` and runs it. Emits **TAP** (Test Anything Protocol) output — `ok N - description` / `not ok N - description` lines, then a `1..N` plan. Exits non-zero on failure. Command Line Tools only; no Xcode required. + +Tests cover: +- `Xorshift64` — same seed → same sequence; different seeds → different sequences +- `NativeMatrixRenderer` — `seedOffset` defaults to `0`; two renderers with different `seedOffset` values diverge after 60 frames ## Code documentation @@ -58,11 +130,14 @@ class MatrixColumn { - Keep the renderer native; do not reintroduce an external terminal or runtime wrapper. - Keep the options UI native AppKit. +- `MatrixRendererLimits.swift` is Foundation-only — do not import `AppKit` or `ScreenSaver` there; it must compile as part of the test binary without those frameworks. - Use `./preview.sh` for fast iteration and `./install.sh` for the real saver bundle. +- When adding a new source file that should be testable, add it to the `swiftc` invocation in `tests.sh`. +- When adding a new source file, also add it to the `swiftc` invocation in `build.sh`. ## Git commits -- Follow Conventional Commits, for example: `feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...` +- Follow Conventional Commits, for example: `feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...`, `test: ...` - Use a short title line. - Add a few very concise bullet points for the meaningful changes. @@ -96,8 +171,9 @@ class MatrixColumn { - Merges to `main` must only come from `development` or `release/*` branches. - Merge to `main` only through a pull request. - Require approval before merging. +- The `development` branch is the default branch for new PRs. -Example: +Example commit: ```text feat: add character size options @@ -111,4 +187,5 @@ feat: add character size options - Trigger releases by running the **Release** GitHub Actions workflow (`workflow_dispatch`) — never manually. - The workflow bumps the version, updates `CHANGELOG.md`, and opens a `release/*` PR targeting `main`. -- Merging that `release/*` PR into `main` triggers the publish job, which creates the actual GitHub release. +- Merging that `release/*` PR into `main` triggers the publish job, which creates the actual GitHub release with a zipped `.saver` bundle attached. +