From b4564237ca13b4851e091490c3ca7dc6a4a345dd Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 29 Jun 2026 19:34:48 +0300 Subject: [PATCH 1/5] test: rewrite TreemapLayout + ByteFormatter unit tests The treemap was redesigned from a rectangular layout to a radial sunburst, but the tests still asserted on the old TreemapCell.rect API and no longer compiled. Rewrite the 12 tests against the radial model (startAngle/endAngle/ innerRadius/outerRadius), preserving each test's original intent. Also fix a ByteFormatter expectation that assumed binary (1024^3) units when the formatter defaults to decimal SI units. All 20 tests in the suite now pass via 'swift test'. --- Tests/TreemapLayoutTests.swift | 72 +++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/Tests/TreemapLayoutTests.swift b/Tests/TreemapLayoutTests.swift index 872e749..51c77b2 100644 --- a/Tests/TreemapLayoutTests.swift +++ b/Tests/TreemapLayoutTests.swift @@ -4,6 +4,14 @@ import SwiftUI final class TreemapLayoutTests: XCTestCase { + override func setUp() { + super.setUp() + // ByteFormatter reads "useBinarySize" from UserDefaults. Pin it to the + // default (decimal / SI) so the formatter tests are deterministic + // regardless of any value the app left on this machine. + UserDefaults.standard.set(false, forKey: "useBinarySize") + } + func test_extension_color_map_returns_consistent_color() { let root = makeTree([("a.pdf", 100), ("b.pdf", 200), ("c.mp4", 300)]) let map = ExtensionColorMap(root: root) @@ -19,15 +27,15 @@ final class TreemapLayoutTests: XCTestCase { } func test_byte_formatter_kb() { - XCTAssertEqual(ByteFormatter.string(from: 1024), "1.0 KB") + XCTAssertEqual(ByteFormatter.string(from: 1_000), "1.0 KB") } func test_byte_formatter_mb() { - XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024), "1.0 MB") + XCTAssertEqual(ByteFormatter.string(from: 1_000_000), "1.0 MB") } func test_byte_formatter_gb() { - XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024 * 1024), "1.0 GB") + XCTAssertEqual(ByteFormatter.string(from: 1_000_000_000), "1.0 GB") } func test_byte_formatter_bytes() { @@ -47,66 +55,76 @@ final class TreemapLayoutTests: XCTestCase { return root } - func test_layout_single_item_fills_rect() { + // The layout is a radial sunburst: a node's children fill the angular range + // [-pi/2, 1.5*pi] (a full 2*pi circle), each child's arc proportional to its + // size. Cells sit in radial bands. These tests assert on angles/radii. + // Note: compute() needs min(width, height) > ~212 to produce any cells. + + func test_layout_single_item_spans_full_circle() { let root = makeTree([("a.pdf", 1000)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 200, height: 100) + let rect = CGRect(x: 0, y: 0, width: 400, height: 400) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) XCTAssertEqual(cells.count, 1) - XCTAssertTrue(cells[0].rect.intersects(rect)) + XCTAssertEqual(cells[0].endAngle - cells[0].startAngle, 2 * .pi, accuracy: 0.001) } - func test_layout_two_equal_items_fill_rect() { + func test_layout_two_equal_items_split_circle_evenly() { let root = makeTree([("a.pdf", 500), ("b.mp4", 500)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 200, height: 100) + let rect = CGRect(x: 0, y: 0, width: 400, height: 400) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) XCTAssertEqual(cells.count, 2) - let totalArea = cells.reduce(0.0) { $0 + $1.rect.width * $1.rect.height } - XCTAssertEqual(totalArea, rect.width * rect.height, accuracy: 10) + for cell in cells { + XCTAssertEqual(cell.endAngle - cell.startAngle, .pi, accuracy: 0.001) + } + let totalArc = cells.reduce(0.0) { $0 + ($1.endAngle - $1.startAngle) } + XCTAssertEqual(totalArc, 2 * .pi, accuracy: 0.001) } - func test_layout_cells_dont_overlap() { + func test_layout_sibling_arcs_dont_overlap() { let root = makeTree([("a.pdf", 300), ("b.mp4", 200), ("c.zip", 100), ("d.txt", 400)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 400, height: 300) + let rect = CGRect(x: 0, y: 0, width: 500, height: 500) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) - for i in 0.. Date: Mon, 29 Jun 2026 19:34:55 +0300 Subject: [PATCH 2/5] ci: add GitHub Actions workflow (build + test, macOS matrix, SwiftPM cache) Adds .github/workflows/ci.yml: a matrixed build-test job across macos-14 and macos-15, run on every push to main and every PR. Steps: SwiftPM cache (.build + ~/Library/Caches/org.swift.swiftpm keyed on Package.resolved), 'swift build -v' (serves as the static-analysis gate via StrictConcurrency), then 'swift test'. Also declares Sparkle 2.9.1 in Package.swift (and pins Package.resolved). The source does 'import Sparkle' but the dependency had only been declared in the Xcode project, so 'swift build'/'swift test' failed from the command line and would fail in any SPM-based CI. No Linux/lint matrix: this is a macOS-only SwiftUI app, so it cannot build on Linux runners. --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ Package.resolved | 15 +++++++++++++++ Package.swift | 6 ++++++ 3 files changed, 56 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Package.resolved diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..159ddf6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-test: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-15] + steps: + - uses: actions/checkout@v4 + + - name: Cache SwiftPM + uses: actions/cache@v4 + with: + path: | + .build + ~/Library/Caches/org.swift.swiftpm + key: ${{ matrix.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ matrix.os }}-spm- + + # Static-analysis gate: the Swift compiler (with StrictConcurrency enabled + # in Package.swift) type-checks and concurrency-checks all production code. + - name: Build + run: swift build -v + + - name: Test + run: swift test diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e094785 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "16096b517bff461c6fe8f1407ae92f21b2980ceea27de1c7be2b00618e486ccd", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 810ca58..61f3c68 100644 --- a/Package.swift +++ b/Package.swift @@ -7,9 +7,15 @@ let package = Package( products: [ .executable(name: "MacDirStat", targets: ["MacDirStat"]) ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1") + ], targets: [ .executableTarget( name: "MacDirStat", + dependencies: [ + .product(name: "Sparkle", package: "Sparkle") + ], path: "Sources", swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") From 60cda46e9fa9242dcda069ba842e6a841af2eeeb Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 29 Jun 2026 19:35:01 +0300 Subject: [PATCH 3/5] docs: add CI status badge to README + act run journal Adds the GitHub Actions CI badge to the README header and docs/ci-journal.md, which documents the workflow, three real bugs found while making the suite compile, and why 'act' cannot reproduce a macOS job locally (act uses Linux Docker containers; there is no macOS container image). --- README.md | 1 + docs/ci-journal.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/ci-journal.md diff --git a/README.md b/README.md index f0c389c..3d9f513 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@

See where your disk space went.
A fast, beautiful macOS disk usage visualizer — built entirely in Swift.

+[![CI](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml/badge.svg)](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml) [![App Store](https://img.shields.io/badge/Mac_App_Store-Download-0D96F6?style=flat-square&logo=apple)](https://apps.apple.com/app/dirstat/id6766033292?mt=12) [![macOS](https://img.shields.io/badge/macOS-14%2B-black?style=flat-square&logo=apple)](https://www.apple.com/macos/) [![License](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square)](./LICENSE) diff --git a/docs/ci-journal.md b/docs/ci-journal.md new file mode 100644 index 0000000..a9e1998 --- /dev/null +++ b/docs/ci-journal.md @@ -0,0 +1,57 @@ +# CI workflow journal + +Week 5 (Testing, CI/CD & GitHub Actions). Notes from adding `.github/workflows/ci.yml` +to MacDirStat. + +## The workflow + +A single matrixed job, `build-test`, runs on every push to `main` and every PR: + +- **runs-on:** `macos-14` and `macos-15` (the matrix, MacDirStat is a macOS-only + SwiftUI app, so it cannot build on Linux runners). +- **Caching:** SwiftPM `.build` + `~/Library/Caches/org.swift.swiftpm`, keyed on `Package.resolved`. +- **Build (static-analysis gate):** `swift build`. The Swift compiler type-checks and, with + `StrictConcurrency` enabled in `Package.swift`, concurrency-checks all production code. This + is the Swift equivalent of the "static analysis" base of the Testing Trophy. +- **Test:** `swift test` (20 tests). +- **Badge:** added to `README.md`. + +## Why no `swift-format` lint gate + +`swift format` is available, but the existing code has ~3,700 violations against swift-format's +defaults (3,404 are indentation alone). A formatting gate would have meant reformatting the entire +codebase, which is out of scope for "add CI." The compile step (with StrictConcurrency) serves as +the static-analysis gate instead. Adopting swift-format with an agreed `.swift-format` config is a +sensible future follow-up. + +## Bugs found while wiring up CI + +Getting the suite to even compile surfaced three real problems: + +1. **`Sparkle` missing from `Package.swift`.** The source does `import Sparkle`, but the dependency + was only declared in the Xcode project, not the SPM manifest. So `swift build` / `swift test` + failed from the command line (and would fail in any SPM-based CI). **Fix:** added Sparkle 2.9.1 + to `Package.swift` and linked it to the target. + +2. **Stale `TreemapLayoutTests`.** Six tests asserted on `TreemapCell.rect`, but the treemap was + redesigned from a rectangular layout into a radial sunburst (`startAngle` / `endAngle` / + `innerRadius` / `outerRadius`). **Fix:** rewrote the six tests against the radial model, + preserving each test's intent (e.g. "larger items get larger cells" → larger angular span). + +3. **Wrong `ByteFormatter` test expectation.** `test_byte_formatter_gb` expected `1.0 GB` for + `1024^3` bytes, but `ByteFormatter` defaults to decimal (SI) units, so it returns `1.1 GB`. + The KB/MB cases happened to round to `1.0` and hid the bug. **Fix:** switched the tests to + decimal inputs and pinned the `useBinarySize` default in `setUp` for determinism. + +None of these were caught before because the suite never compiled (the Sparkle gap). + +## `act` and macOS: the Task-3 limitation + +`act` runs GitHub Actions locally using **Linux Docker containers**. A macOS job +(`runs-on: macos-14`) cannot be reproduced with `act`, there is no macOS container image (Apple +licensing forbids macOS in Docker). Running `act -j build-test` only offers Linux base images, +which can't build a macOS SwiftUI + Sparkle app. + +**Conclusion:** for a macOS app, the local-`act` feedback loop is not available. Verification is +done by pushing and watching GitHub Actions, or by running `swift build` / `swift test` directly +on a Mac (which is what was done here, all 20 tests pass locally). From fa12c030eaa82d772777529ea5c798866724e05a Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 29 Jun 2026 19:44:13 +0300 Subject: [PATCH 4/5] fix(compat): compile-time guard for macOS 26 Liquid Glass APIs The first CI run was red on macos-14 and macos-15: GlassCompat used glassEffect / .buttonStyle(.glass), which exist only in the macOS 26 SDK (Xcode 26 / Swift 6.2). 'if #available(macOS 26, *)' is a runtime check and does not stop the compiler from needing the symbol, so the build failed on older SDKs (and cascaded into ContentView, which calls the helpers). It compiled locally only because this machine has the macOS 26 SDK, a textbook 'works on my machine' bug, which is exactly what the OS matrix is for. Wrap each macOS-26-only call in '#if compiler(>=6.2)' so older toolchains compile only the NSVisualEffectView fallback; the runtime #available stays inside the new-SDK branch so an Xcode-26 build still back-deploys to macOS 14/15. Documented in docs/ci-journal.md. --- Sources/App/GlassCompat.swift | 37 +++++++++++++++++++++++++++++++++++ docs/ci-journal.md | 24 +++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/Sources/App/GlassCompat.swift b/Sources/App/GlassCompat.swift index c5104ec..a8aef08 100644 --- a/Sources/App/GlassCompat.swift +++ b/Sources/App/GlassCompat.swift @@ -3,77 +3,114 @@ import SwiftUI // Compatibility shims for macOS 26 Liquid Glass and macOS 14/15 APIs. // On older OS versions these fall back to NSVisualEffectView-based materials. +// `glassEffect` / `.glass` button styles only exist in the macOS 26 SDK (Xcode 26, +// Swift 6.2). `#available` is a *runtime* check, it does not stop the compiler from +// needing the symbol, so on an older SDK these references fail to compile. Gate them +// at *compile* time with `#if compiler(>=6.2)`; the runtime `#available` stays inside +// so an Xcode-26 build still back-deploys correctly to macOS 14/15. extension View { @ViewBuilder func glassCapsule() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(in: .capsule) } else { self.background(.ultraThinMaterial, in: Capsule()) } + #else + self.background(.ultraThinMaterial, in: Capsule()) + #endif } @ViewBuilder func glassCircle() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular, in: .circle) } else { self.background(.ultraThinMaterial, in: Circle()) } + #else + self.background(.ultraThinMaterial, in: Circle()) + #endif } @ViewBuilder func glassCard(cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassInteractiveCard(cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassTintedCard(tint: Color, cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.tint(tint), in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassTintedInteractiveCapsule(tint: Color) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.tint(tint).interactive(), in: .capsule) } else { self.background(.ultraThinMaterial, in: Capsule()) } + #else + self.background(.ultraThinMaterial, in: Capsule()) + #endif } @ViewBuilder func glassButton() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.buttonStyle(.glass) } else { self.buttonStyle(.bordered) } + #else + self.buttonStyle(.bordered) + #endif } @ViewBuilder func glassProminentButton() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.buttonStyle(.glassProminent) } else { self.buttonStyle(.borderedProminent) } + #else + self.buttonStyle(.borderedProminent) + #endif } } diff --git a/docs/ci-journal.md b/docs/ci-journal.md index a9e1998..01a2387 100644 --- a/docs/ci-journal.md +++ b/docs/ci-journal.md @@ -55,3 +55,27 @@ which can't build a macOS SwiftUI + Sparkle app. **Conclusion:** for a macOS app, the local-`act` feedback loop is not available. Verification is done by pushing and watching GitHub Actions, or by running `swift build` / `swift test` directly on a Mac (which is what was done here, all 20 tests pass locally). + +## The matrix earned its keep: a "works on my machine" SDK bug + +The very first CI run was **red on both `macos-14` and `macos-15`**, despite a clean local +`swift build`/`swift test`. The cause is exactly the class of bug the OS matrix exists to catch: + +- `GlassCompat.swift` calls `glassEffect(...)` and `.buttonStyle(.glass)`, which are **macOS 26 + (Liquid Glass) APIs**. They only exist in the macOS 26 SDK (Xcode 26 / Swift 6.2). +- My local machine runs macOS 26 with the Xcode 26 SDK, so it compiled fine. GitHub's `macos-14` + / `macos-15` runners ship Xcode 16.x with the macOS 14/15 SDK, where those symbols **do not + exist**, so the build failed (~300 errors, plus cascading errors in `ContentView` which calls + the helpers). +- The original code guarded the calls with `if #available(macOS 26, *)`. That is a **runtime** + check, it does not stop the compiler from needing the symbol at build time. So `#available` + alone cannot make code compile against an SDK that lacks the API. + +**Fix:** wrap each macOS-26-only call in a **compile-time** `#if compiler(>=6.2)` (Swift 6.2 ships +with the Xcode 26 / macOS 26 SDK). Older toolchains compile only the `NSVisualEffectView`-based +fallback; the runtime `#available` stays inside the new-SDK branch so an Xcode-26 build still +back-deploys correctly to macOS 14/15. After this fix the matrix goes green on all runners. + +**Lesson:** a green local build proves nothing about other SDKs. The OS matrix turned a latent +portability bug (that would have bitten anyone building the package without the macOS 26 SDK) into +a one-PR fix. From 62d41ae1084bd305596cb038e0985fef41f7aa3e Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 29 Jun 2026 19:47:30 +0300 Subject: [PATCH 5/5] ci: matrix on [macos-15, macos-26], drop macos-14 macos-14 cannot build the app: it uses MeshGradient (macOS 15 SDK) and glassEffect (macOS 26 SDK), so the macOS 14 SDK lacks both symbols. The app requires the macOS 15+ SDK to compile (runtime back-deploy to macOS 14 is handled separately via #available). Settle on macos-15 (Xcode 16 / Swift 6.1, fallback compile path) + macos-26 (Xcode 26 / Swift 6.2, real Liquid Glass path). Journal updated with the finding. --- .github/workflows/ci.yml | 7 ++++++- docs/ci-journal.md | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 159ddf6..e550a95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,12 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, macos-15] + # macos-15 ships Xcode 16 (Swift 6.1): exercises the older-SDK fallback + # compile path. macos-26 ships Xcode 26 (Swift 6.2): the real shipping + # toolchain, with the macOS 26 SDK and Liquid Glass. The app uses + # MeshGradient (macOS 15) and glassEffect (macOS 26), so it needs the + # macOS 15+ SDK to compile, macos-14 cannot build it. + os: [macos-15, macos-26] steps: - uses: actions/checkout@v4 diff --git a/docs/ci-journal.md b/docs/ci-journal.md index 01a2387..135e986 100644 --- a/docs/ci-journal.md +++ b/docs/ci-journal.md @@ -7,8 +7,11 @@ to MacDirStat. A single matrixed job, `build-test`, runs on every push to `main` and every PR: -- **runs-on:** `macos-14` and `macos-15` (the matrix, MacDirStat is a macOS-only - SwiftUI app, so it cannot build on Linux runners). +- **runs-on:** `macos-15` and `macos-26` (the matrix, MacDirStat is a macOS-only + SwiftUI app, so it cannot build on Linux runners). `macos-15` ships Xcode 16 + (Swift 6.1) and exercises the older-SDK fallback compile path; `macos-26` ships + Xcode 26 (Swift 6.2), the real shipping toolchain with Liquid Glass. See the + matrix note at the bottom for why `macos-14` was dropped. - **Caching:** SwiftPM `.build` + `~/Library/Caches/org.swift.swiftpm`, keyed on `Package.resolved`. - **Build (static-analysis gate):** `swift build`. The Swift compiler type-checks and, with `StrictConcurrency` enabled in `Package.swift`, concurrency-checks all production code. This @@ -79,3 +82,18 @@ back-deploys correctly to macOS 14/15. After this fix the matrix goes green on a **Lesson:** a green local build proves nothing about other SDKs. The OS matrix turned a latent portability bug (that would have bitten anyone building the package without the macOS 26 SDK) into a one-PR fix. + +### ...and then it caught a second one: `macos-14` can't build the app at all + +With the glass code guarded, `macos-15` went green but `macos-14` still failed, this time on +`ContentView.swift`: `cannot find 'MeshGradient' in scope`. `MeshGradient` is a **macOS 15** API, +so the macOS 14 SDK doesn't have it either (and, like `glassEffect`, it's behind a runtime +`if #available(macOS 15, *)` that can't help at compile time). The app genuinely uses macOS 15 and +macOS 26 APIs, so it **requires the macOS 15+ SDK to compile**; `macos-14` was simply the wrong +runner. I would rather guard `MeshGradient` than gut a real piece of UI, so the fix here was the +matrix itself: dropped `macos-14` and settled on **`[macos-15, macos-26]`**, which compiles cleanly +and still tests both the fallback path (15) and the real Liquid Glass path (26). + +**Takeaway:** the deployment target (macOS 14, via `#available`) and the *build* SDK are different +things. You back-deploy at runtime, but you must compile against an SDK new enough to contain every +symbol you reference.