From b2b85ae8358408545e2e11e99f5f53c2ff80dc7a Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 11 Sep 2026 20:02:57 +0100 Subject: [PATCH 01/25] chore(m9): retarget project spec, CI, and core syntax to macOS 12 / Swift 5.7 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitea/workflows/macos.yml | 12 ++++++++-- Packages/ICCeryCore/Package.swift | 4 ++-- .../ICCeryCore/Argyll/ArgyllRunner.swift | 6 ++--- .../ICCeryCore/CGATS/CGATSParser.swift | 4 ++-- .../ICCeryCore/Process/ProcessManager.swift | 2 +- Sources/ICCery/SettingsViewModel.swift | 2 +- Sources/ICCery/WizardViewModel.swift | 2 +- project.yml | 24 +++++++++---------- 8 files changed, 32 insertions(+), 24 deletions(-) diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index f82f138..7a0b901 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -14,13 +14,21 @@ jobs: build-and-test: # Prefer a self-hosted Mac runner if your Gitea has one. If not, # macos-14 works for this pipeline. - runs-on: macos-14 + runs-on: macos-12 env: DERIVED: build/DerivedData-test steps: - name: Checkout uses: actions/checkout@v4 + - name: Assert Xcode 14 toolchain + run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1) + + - name: Ensure host tools + run: | + command -v xcodegen || brew install xcodegen + python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild + - name: Generate Xcode project run: xcodegen generate --spec project.yml @@ -120,7 +128,7 @@ jobs: package: needs: build-and-test - runs-on: macos-14 + runs-on: macos-12 if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout diff --git a/Packages/ICCeryCore/Package.swift b/Packages/ICCeryCore/Package.swift index 7de992e..5fde803 100644 --- a/Packages/ICCeryCore/Package.swift +++ b/Packages/ICCeryCore/Package.swift @@ -1,9 +1,9 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 5.7 import PackageDescription let package = Package( name: "ICCeryCore", - platforms: [.macOS(.v14)], + platforms: [.macOS(.v12)], products: [ .library(name: "ICCeryCore", targets: ["ICCeryCore"]), ], diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift index e3aa959..c2eb510 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -200,7 +200,7 @@ public struct ArgyllRunner: Sendable { await processManager.kill(id: id) var attempts = 0 while await processManager.isRunning(id), attempts < 30 { - try? await Task.sleep(for: .milliseconds(100)) + try? await Task.sleep(nanoseconds: 100_000_000) attempts += 1 } } @@ -243,7 +243,7 @@ public struct ArgyllRunner: Sendable { if flushPartialLines { dotFlushTask = Task { [processManager] in while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(500)) + try? await Task.sleep(nanoseconds: 500_000_000) if Task.isCancelled { break } await processManager.flushPartialLine(id: processId) } @@ -592,7 +592,7 @@ public struct ArgyllRunner: Sendable { await processManager.setPreKillHook(id: processId) { [processManager] in if isXY { try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) - try? await Task.sleep(for: .milliseconds(500)) + try? await Task.sleep(nanoseconds: 500_000_000) } } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift index fec3f09..327603a 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift @@ -72,7 +72,7 @@ public enum CGATSParser { public static func parse( _ contents: String, sourceURL: URL? = nil - ) throws(CGATSParseError) -> CGATSDataset { + ) throws -> CGATSDataset { guard !contents.isEmpty else { throw .emptyFile } let ext = sourceURL?.pathExtension.lowercased() ?? "" @@ -213,7 +213,7 @@ public enum CGATSParser { private static func preprocess( _ contents: String, isCSV: Bool - ) throws(CGATSParseError) -> (CGATSFormat, [String]) { + ) throws -> (CGATSFormat, [String]) { let allLines = contents.components(separatedBy: .newlines) var lines = [String]() diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift index f4ba148..6e0f4eb 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -559,7 +559,7 @@ public actor ProcessManager { // Start a watchdog in case the `readabilityHandler` EOFs never // arrive after the process exits (e.g. a hung pipe). child.finalizeTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(2)) + try? await Task.sleep(nanoseconds: 2_000_000_000) guard let self else { return } await self.forceKill(id: id) await self.forceFinalize(id: id) diff --git a/Sources/ICCery/SettingsViewModel.swift b/Sources/ICCery/SettingsViewModel.swift index 2f81970..d231309 100644 --- a/Sources/ICCery/SettingsViewModel.swift +++ b/Sources/ICCery/SettingsViewModel.swift @@ -33,7 +33,7 @@ final class SettingsViewModel { sink.applySettings(settings) savedFlash = true Task { - try? await Task.sleep(for: .seconds(1.5)) + try? await Task.sleep(nanoseconds: 1_500_000_000) savedFlash = false } return true diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift index 2bf9747..55adc72 100644 --- a/Sources/ICCery/WizardViewModel.swift +++ b/Sources/ICCery/WizardViewModel.swift @@ -201,7 +201,7 @@ final class WizardViewModel { self.notice = notice if let delay = notice.autoHideAfter { noticeDismissTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(delay)) + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) guard !Task.isCancelled else { return } if self?.notice?.id == notice.id { self?.notice = nil diff --git a/project.yml b/project.yml index e172855..46bc694 100644 --- a/project.yml +++ b/project.yml @@ -2,7 +2,7 @@ name: ICCery options: bundleIdPrefix: com.gronod deploymentTarget: - macOS: "14.0" + macOS: "12.0" groupSortPosition: top packages: @@ -13,7 +13,7 @@ targets: ICCery: type: application platform: macOS - deploymentTarget: "14.0" + deploymentTarget: "12.0" sources: - path: Sources/ICCery - path: Resources @@ -46,7 +46,7 @@ targets: PRODUCT_BUNDLE_PACKAGE_TYPE: APPL GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_CFBundleDisplayName: ICCery - INFOPLIST_KEY_LSMinimumSystemVersion: "14.0" + INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." MARKETING_VERSION: "2.0.0" @@ -58,15 +58,15 @@ targets: ENABLE_APP_SANDBOX: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor - SWIFT_VERSION: "6.0" - SWIFT_STRICT_CONCURRENCY: complete - MACOSX_DEPLOYMENT_TARGET: "14.0" + SWIFT_VERSION: "5.0" + OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"] + MACOSX_DEPLOYMENT_TARGET: "12.0" ARCHS: "$(ARCHS_STANDARD)" ICCeryCoreTests: type: bundle.unit-test platform: macOS - deploymentTarget: "14.0" + deploymentTarget: "12.0" sources: - path: Tests/ICCeryCoreTests dependencies: @@ -79,13 +79,13 @@ targets: TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery" GENERATE_INFOPLIST_FILE: YES CODE_SIGN_IDENTITY: "-" - SWIFT_VERSION: "6.0" - MACOSX_DEPLOYMENT_TARGET: "14.0" + SWIFT_VERSION: "5.0" + MACOSX_DEPLOYMENT_TARGET: "12.0" ICCeryUITests: type: bundle.ui-testing platform: macOS - deploymentTarget: "14.0" + deploymentTarget: "12.0" sources: - path: Tests/ICCeryUITests dependencies: @@ -95,8 +95,8 @@ targets: TEST_TARGET_NAME: ICCery GENERATE_INFOPLIST_FILE: YES CODE_SIGN_IDENTITY: "-" - SWIFT_VERSION: "6.0" - MACOSX_DEPLOYMENT_TARGET: "14.0" + SWIFT_VERSION: "5.0" + MACOSX_DEPLOYMENT_TARGET: "12.0" schemes: ICCery: -- 2.39.5 From 150d094632ddecac0320beb298482c5dd8635b7b Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 11 Sep 2026 20:43:42 +0100 Subject: [PATCH 02/25] test(m9): convert pure core test suites from Swift Testing to XCTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 (Phase 1a) of the M9 Monterey retarget. Converts all pure Core suites (argv builders, CGATS/CUPS/profcheck/gamut-mesh parsers, colour math, file/artefact helpers, classifiers) to XCTestCase, and adds the shared assertAsyncThrows helper for the async-throws sites that Slice 3 will convert. Deferred to Slice 3 (kept on Swift Testing in mixed files): ProcessManagerTests, ArgyllRunner*Tests, ProcessRunSupportTests, store/UI suites, and the runner suites embedded in TargenTests / PrinttargTests / ProcessManagerTests. Also qualifies six bare `throw .case` expressions in CGATSParser with CGATSParseError — they only compiled under typed throws, which Slice 1 demoted to untyped `throws`; without this the ICCeryCore target fails to build and no test gate can run. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/CGATS/CGATSParser.swift | 12 +- Tests/ICCeryCoreTests/AppPathsTests.swift | 28 ++- Tests/ICCeryCoreTests/ApplycalArgsTests.swift | 15 +- Tests/ICCeryCoreTests/ArgsBuilderTests.swift | 96 ++++---- .../ICCeryCoreTests/ArtefactFilesTests.swift | 106 ++++----- .../ICCeryCoreTests/BinaryResolverTests.swift | 33 ++- Tests/ICCeryCoreTests/CGATSParserTests.swift | 87 +++---- .../CalibrationIdentityTests.swift | 79 +++---- .../CalibrationTargenArgsTests.swift | 34 +-- Tests/ICCeryCoreTests/ColprofArgsTests.swift | 55 ++--- .../ColprofProgressTests.swift | 24 +- .../CupsOptionsFilterTests.swift | 80 +++---- Tests/ICCeryCoreTests/CupsParserTests.swift | 113 ++++----- Tests/ICCeryCoreTests/DriftAlertTests.swift | 50 ++-- Tests/ICCeryCoreTests/FileHelpersTests.swift | 73 +++--- .../GamutGeometryBuilderTests.swift | 10 +- .../GamutMeshParserTests.swift | 69 +++--- Tests/ICCeryCoreTests/IccgamutArgsTests.swift | 10 +- Tests/ICCeryCoreTests/LpArgsTests.swift | 101 ++++---- Tests/ICCeryCoreTests/MeasurementTests.swift | 221 ++++++++---------- Tests/ICCeryCoreTests/PrintcalArgsTests.swift | 34 ++- Tests/ICCeryCoreTests/PrinttargTests.swift | 188 +++++++-------- .../ICCeryCoreTests/ProcessManagerTests.swift | 62 +++-- .../ICCeryCoreTests/ProfcheckArgsTests.swift | 10 +- .../ProfcheckParserTests.swift | 68 +++--- Tests/ICCeryCoreTests/TargenTests.swift | 90 +++---- .../ICCeryCoreTests/TestAppEnvironment.swift | 1 - .../XCTestCase+AsyncThrows.swift | 21 ++ 28 files changed, 779 insertions(+), 991 deletions(-) create mode 100644 Tests/ICCeryCoreTests/XCTestCase+AsyncThrows.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift index 327603a..8b0fc25 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift @@ -73,7 +73,7 @@ public enum CGATSParser { _ contents: String, sourceURL: URL? = nil ) throws -> CGATSDataset { - guard !contents.isEmpty else { throw .emptyFile } + guard !contents.isEmpty else { throw CGATSParseError.emptyFile } let ext = sourceURL?.pathExtension.lowercased() ?? "" let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines) @@ -101,10 +101,10 @@ public enum CGATSParser { } guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else { - throw .missingBeginDataFormat + throw CGATSParseError.missingBeginDataFormat } guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else { - throw .missingBeginData + throw CGATSParseError.missingBeginData } let rawFieldNames = splitFields(lines[formatStart + 1]) @@ -139,7 +139,7 @@ public enum CGATSParser { let lineIndex = dataStart + offset let rawRow = splitFields(lines[lineIndex]) guard rawRow.count == fieldNames.count else { - throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count) + throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count) } var sample = RawSample(id: String(offset), lineIndex: lineIndex) @@ -153,7 +153,7 @@ public enum CGATSParser { groupMax[group, default: 0] = max(groupMax[group, default: 0], number) } } else if !cleaned.isEmpty { - throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1) + throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1) } } else { sample.strings[name] = raw @@ -239,7 +239,7 @@ public enum CGATSParser { lines.append(line) } - guard !lines.isEmpty else { throw .emptyFile } + guard !lines.isEmpty else { throw CGATSParseError.emptyFile } // Wrap a bare CSV / ISO28178 file in the canonical CGATS block // structure so the boundary-based parser below can handle it. diff --git a/Tests/ICCeryCoreTests/AppPathsTests.swift b/Tests/ICCeryCoreTests/AppPathsTests.swift index 2316056..130e0d8 100644 --- a/Tests/ICCeryCoreTests/AppPathsTests.swift +++ b/Tests/ICCeryCoreTests/AppPathsTests.swift @@ -1,27 +1,25 @@ -import Testing +import XCTest import Foundation @testable import ICCeryCore -@Suite("AppPaths") -struct AppPathsTests { - @Test func appDataDirUsesBundleID() { - #expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2")) +final class AppPathsTests: XCTestCase { + func testAppDataDirUsesBundleID() { + XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2")) } - @Test func logFileIsUnderLibraryLogs() { - #expect(AppPaths.logFile.lastPathComponent == "iccery.log") - #expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2")) + func testLogFileIsUnderLibraryLogs() { + XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log") + XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2")) } - @Test func bundledArgyllDirIsInsideResources() { - #expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll") + func testBundledArgyllDirIsInsideResources() { + XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll") } } -@Suite("WizardStage") -struct WizardStageTests { - @Test func stepperOrderIsOneThroughFive() { - #expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5]) - #expect(WizardStage.calibrate.stepperIndex == nil) +final class WizardStageTests: XCTestCase { + func testStepperOrderIsOneThroughFive() { + XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5]) + XCTAssertNil(WizardStage.calibrate.stepperIndex) } } diff --git a/Tests/ICCeryCoreTests/ApplycalArgsTests.swift b/Tests/ICCeryCoreTests/ApplycalArgsTests.swift index fb10001..b4cb09f 100644 --- a/Tests/ICCeryCoreTests/ApplycalArgsTests.swift +++ b/Tests/ICCeryCoreTests/ApplycalArgsTests.swift @@ -1,22 +1,19 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("ApplycalArgs") -struct ApplycalArgsTests { +final class ApplycalArgsTests: XCTestCase { - @Test("Apply argv") - func applyArgv() throws { + func testApplyArgv() throws { let config = ApplycalConfig( calibrationPath: "/tmp/cal.cal", inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc") ) let args = try ApplycalArgs.build(config: config) - #expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"]) + XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"]) } - @Test("Unapply is emitted when the caller explicitly sets it") - func unapplyEmittedWhenConfigSet() throws { + func testUnapplyEmittedWhenConfigSet() throws { let config = ApplycalConfig( calibrationPath: "/tmp/cal.cal", inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"), @@ -25,6 +22,6 @@ struct ApplycalArgsTests { let args = try ApplycalArgs.build(config: config) // Builder emits -u only when the caller explicitly sets unapply. // The UI layer never passes unapply: true in v2.0. - #expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"]) + XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"]) } } diff --git a/Tests/ICCeryCoreTests/ArgsBuilderTests.swift b/Tests/ICCeryCoreTests/ArgsBuilderTests.swift index 51e9605..05596c3 100644 --- a/Tests/ICCeryCoreTests/ArgsBuilderTests.swift +++ b/Tests/ICCeryCoreTests/ArgsBuilderTests.swift @@ -1,94 +1,80 @@ -import Testing +import XCTest import Foundation @testable import ICCeryCore -@Suite("ArgsBuilder") -struct ArgsBuilderTests { +final class ArgsBuilderTests: XCTestCase { // MARK: - option - @Test("option: nil emits nothing") - func optionNil() { - #expect(ArgsBuilder.option("-f", nil) == []) + func testOptionNil() { + XCTAssertEqual(ArgsBuilder.option("-f", nil), []) } - @Test("option: present value emits flag and value verbatim") - func optionPresent() { - #expect(ArgsBuilder.option("-f", "abc") == ["-f", "abc"]) - #expect(ArgsBuilder.option("-f", "") == ["-f", ""]) - #expect(ArgsBuilder.option("-f", " padded ") == ["-f", " padded "]) + func testOptionPresent() { + XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"]) + XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""]) + XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "]) } // MARK: - optionIfNonEmpty - @Test("optionIfNonEmpty: nil and empty emit nothing") - func optionIfNonEmptyNilEmpty() { - #expect(ArgsBuilder.optionIfNonEmpty("-d", nil) == []) - #expect(ArgsBuilder.optionIfNonEmpty("-d", "") == []) + func testOptionIfNonEmptyNilEmpty() { + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), []) + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), []) } - @Test("optionIfNonEmpty: whitespace-only emits nothing") - func optionIfNonEmptyWhitespace() { - #expect(ArgsBuilder.optionIfNonEmpty("-d", " ") == []) - #expect(ArgsBuilder.optionIfNonEmpty("-d", " \t\n ") == []) + func testOptionIfNonEmptyWhitespace() { + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), []) + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), []) } - @Test("optionIfNonEmpty: trims surrounding whitespace") - func optionIfNonEmptyTrims() { - #expect(ArgsBuilder.optionIfNonEmpty("-d", " label ") == ["-d", "label"]) - #expect(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n") == ["-d", "cal.cal"]) + func testOptionIfNonEmptyTrims() { + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"]) + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"]) } // MARK: - optionUnlessApprox - @Test("optionUnlessApprox: nil emits nothing") - func optionUnlessApproxNil() { - #expect(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50) == []) + func testOptionUnlessApproxNil() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), []) } - @Test("optionUnlessApprox: exact skip value emits nothing") - func optionUnlessApproxExactSkip() { - #expect(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50) == []) - #expect(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0) == []) + func testOptionUnlessApproxExactSkip() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), []) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), []) } - @Test("optionUnlessApprox: within epsilon emits nothing") - func optionUnlessApproxWithinEpsilon() { - #expect(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50) == []) - #expect(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0) == []) + func testOptionUnlessApproxWithinEpsilon() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), []) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), []) } - @Test("optionUnlessApprox: outside epsilon emits flag") - func optionUnlessApproxOutsideEpsilon() { - #expect(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50) == ["-N", "0.75"]) - #expect(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0) == ["-V", "1.50"]) - #expect(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50) == ["-N", "0.50"]) + func testOptionUnlessApproxOutsideEpsilon() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"]) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"]) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"]) } - @Test("optionUnlessApprox: POSIX formatting is locale-stable") - func optionUnlessApproxPOSIX() { + func testOptionUnlessApproxPOSIX() { // 1234.5 must never produce a grouping separator or comma decimal. - #expect(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0) == ["-p", "1234.50"]) - #expect(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0) == ["-p", "2.00"]) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"]) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"]) } - @Test("optionUnlessApprox: custom epsilon and format honoured") - func optionUnlessApproxCustom() { - #expect(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01) == []) - #expect(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f") == ["-x", "1.5"]) + func testOptionUnlessApproxCustom() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), []) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"]) } // MARK: - flag - @Test("flag: true emits the bare flag") - func flagTrue() { - #expect(ArgsBuilder.flag("-G", when: true) == ["-G"]) - #expect(ArgsBuilder.flag("-r", when: true) == ["-r"]) + func testFlagTrue() { + XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"]) + XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"]) } - @Test("flag: false emits nothing") - func flagFalse() { - #expect(ArgsBuilder.flag("-G", when: false) == []) - #expect(ArgsBuilder.flag("-r", when: false) == []) + func testFlagFalse() { + XCTAssertEqual(ArgsBuilder.flag("-G", when: false), []) + XCTAssertEqual(ArgsBuilder.flag("-r", when: false), []) } } diff --git a/Tests/ICCeryCoreTests/ArtefactFilesTests.swift b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift index aa85368..f5c7346 100644 --- a/Tests/ICCeryCoreTests/ArtefactFilesTests.swift +++ b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift @@ -1,4 +1,4 @@ -import Testing +import XCTest import Foundation import ImageIO import UniformTypeIdentifiers @@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL { .appendingPathComponent(name) } -@Suite("Ti2Header") -struct Ti2HeaderTests { - @Test func parsesKeywordsAndSibling() throws { +final class Ti2HeaderTests: XCTestCase { + func testParsesKeywordsAndSibling() throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("iccery-ti2-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -31,18 +30,18 @@ struct Ti2HeaderTests { ) let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2")) - #expect(h.instrument == "i1iO") - #expect(h.patchCount == 800) - #expect(h.pageCount == 3) - #expect(h.hasSiblingTi1) + XCTAssertEqual(h.instrument, "i1iO") + XCTAssertEqual(h.patchCount, 800) + XCTAssertEqual(h.pageCount, 3) + XCTAssertTrue(h.hasSiblingTi1) } - @Test func missingFileYieldsEmptyHeader() { + func testMissingFileYieldsEmptyHeader() { let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2")) - #expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1) + XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1) } - @Test func numberOfFieldsIsNotPatchCount() throws { + func testNumberOfFieldsIsNotPatchCount() throws { let url = tempURL("t.ti2") try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true @@ -50,12 +49,11 @@ struct Ti2HeaderTests { try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write( to: url, atomically: true, encoding: .utf8 ) - #expect(Ti2Header.parse(url).patchCount == 52) + XCTAssertEqual(Ti2Header.parse(url).patchCount, 52) } } -@Suite("TiffPreview") -struct TiffPreviewTests { +final class TiffPreviewTests: XCTestCase { /// Builds a real 2000×1000 TIFF in a temp dir via ImageIO. private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL { let url = tempURL("big.tif") @@ -81,50 +79,48 @@ struct TiffPreviewTests { return url } - @Test func producesCappedPNG() throws { + func testProducesCappedPNG() throws { let tiff = try makeTiff() let png = TiffPreview.previewPNG(tiff: tiff) - #expect(png != nil) + XCTAssertNotNil(png) // PNG magic - #expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) + XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) // Verify the cap by decoding the thumbnail header. let src = CGImageSourceCreateWithData(png! as CFData, nil)! let img = CGImageSourceCreateImageAtIndex(src, 0, nil)! - #expect(max(img.width, img.height) <= TiffPreview.maxEdge) - #expect(img.width == 1200) + XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge) + XCTAssertEqual(img.width, 1200) } - @Test func nonTiffReturnsNil() throws { + func testNonTiffReturnsNil() throws { let url = tempURL("not-tiff.txt") try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) try "hello".write(to: url, atomically: true, encoding: .utf8) - #expect(TiffPreview.previewPNG(tiff: url) == nil) + XCTAssertNil(TiffPreview.previewPNG(tiff: url)) } } -@Suite("ArtefactFiles") -struct ArtefactFilesTests { - @Test func base64RoundTrip() throws { +final class ArtefactFilesTests: XCTestCase { + func testBase64RoundTrip() throws { let url = tempURL("a.txt") try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) try "hello".write(to: url, atomically: true, encoding: .utf8) let b64 = try ArtefactFiles.readBase64(url) - #expect(Data(base64Encoded: b64) == Data("hello".utf8)) + XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8)) } - @Test func defaultWorkingDirExists() { - #expect(FileManager.default.fileExists( + func testDefaultWorkingDirExists() { + XCTAssertTrue(FileManager.default.fileExists( atPath: ArtefactFiles.defaultWorkingDirectory().path )) } } -@Suite("ArtefactProbe profile resolve") -struct ArtefactProbeProfileTests { +final class ArtefactProbeProfileTests: XCTestCase { private func makeDir() throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("probe-\(UUID().uuidString)") @@ -134,93 +130,83 @@ struct ArtefactProbeProfileTests { // MARK: Basename probe matrix (#69) - @Test("basename probe: only .icc exists") - func onlyIcc() throws { + func testOnlyIcc() throws { let dir = try makeDir() let icc = dir.appendingPathComponent("job.icc") try Data("icc".utf8).write(to: icc) - #expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path) + XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path) } - @Test("basename probe: only .icm exists") - func onlyIcm() throws { + func testOnlyIcm() throws { let dir = try makeDir() let icm = dir.appendingPathComponent("job.icm") try Data("icm".utf8).write(to: icm) - #expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path) + XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path) } - @Test("basename probe prefers .icm") - func icmWins() throws { + func testIcmWins() throws { let dir = try makeDir() try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) let icm = dir.appendingPathComponent("job.icm") try Data("icm".utf8).write(to: icm) let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir) - #expect(url?.path == icm.path) + XCTAssertEqual(url?.path, icm.path) } - @Test("basename probe: neither exists returns nil") - func neitherExists() throws { + func testNeitherExists() throws { let dir = try makeDir() - #expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil) + XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)) } // MARK: Explicit URL matrix (#69 / #83) - @Test("explicit existing .icc wins even when .icm exists") - func explicitIccWins() throws { + func testExplicitIccWins() throws { let dir = try makeDir() let icc = dir.appendingPathComponent("job.icc") try Data("icc".utf8).write(to: icc) try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm")) - #expect(ArtefactProbe.resolveProfile(icc).path == icc.path) + XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path) } - @Test("explicit existing .icm wins even when .icc exists") - func explicitIcmWins() throws { + func testExplicitIcmWins() throws { let dir = try makeDir() try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) let icm = dir.appendingPathComponent("job.icm") try Data("icm".utf8).write(to: icm) - #expect(ArtefactProbe.resolveProfile(icm).path == icm.path) + XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path) } - @Test("explicit missing .icc flips to sibling .icm") - func flipExtension() throws { + func testFlipExtension() throws { let dir = try makeDir() let icc = dir.appendingPathComponent("job.icc") let icm = dir.appendingPathComponent("job.icm") try Data("icm".utf8).write(to: icm) let resolved = ArtefactProbe.resolveProfile(icc) - #expect(resolved.path == icm.path) + XCTAssertEqual(resolved.path, icm.path) } - @Test("explicit missing .icm flips to sibling .icc") - func flipToIcc() throws { + func testFlipToIcc() throws { let dir = try makeDir() let icc = dir.appendingPathComponent("job.icc") let icm = dir.appendingPathComponent("job.icm") try Data("icc".utf8).write(to: icc) - #expect(ArtefactProbe.resolveProfile(icm).path == icc.path) + XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path) } - @Test("explicit missing both returns the original URL") - func missingBoth() throws { + func testMissingBoth() throws { let dir = try makeDir() let icc = dir.appendingPathComponent("job.icc") - #expect(ArtefactProbe.resolveProfile(icc).path == icc.path) + XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path) } - @Test("unrelated extension is never rewritten") - func unrelatedExtension() throws { + func testUnrelatedExtension() throws { let dir = try makeDir() let mpp = dir.appendingPathComponent("job.mpp") let icc = dir.appendingPathComponent("job.icc") try Data("icc".utf8).write(to: icc) // Even though a sibling .icc exists, a missing .mpp stays .mpp. - #expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path) + XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path) let txt = dir.appendingPathComponent("job.txt") - #expect(ArtefactProbe.resolveProfile(txt).path == txt.path) + XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path) } } diff --git a/Tests/ICCeryCoreTests/BinaryResolverTests.swift b/Tests/ICCeryCoreTests/BinaryResolverTests.swift index c1c7c76..06c1c5f 100644 --- a/Tests/ICCeryCoreTests/BinaryResolverTests.swift +++ b/Tests/ICCeryCoreTests/BinaryResolverTests.swift @@ -1,9 +1,8 @@ -import Testing +import XCTest import Foundation @testable import ICCeryCore -@Suite("BinaryResolver") -struct BinaryResolverTests { +final class BinaryResolverTests: XCTestCase { private func makeTree(_ body: (URL) throws -> Void) throws -> URL { let root = FileManager.default.temporaryDirectory @@ -22,16 +21,16 @@ struct BinaryResolverTests { } } - @Test func overrideDirWinsWhenFileExists() throws { + func testOverrideDirWinsWhenFileExists() throws { let override = try makeTree { root in try touch(root.appendingPathComponent("targen")) } let bundled = try makeTree { _ in } let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) - #expect(r.resolve("targen") == override.appendingPathComponent("targen")) + XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen")) } - @Test func overrideFallsThroughWhenMissing() throws { + func testOverrideFallsThroughWhenMissing() throws { let override = try makeTree { _ in } let bundled = try makeTree { root in let dir = root.appendingPathComponent("macos-universal") @@ -39,10 +38,10 @@ struct BinaryResolverTests { try touch(dir.appendingPathComponent("instlist")) } let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) - #expect(r.resolve("targen").path.contains("macos-universal/targen")) + XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen")) } - @Test func universalPreferredWhenMarkerPresent() throws { + func testUniversalPreferredWhenMarkerPresent() throws { let bundled = try makeTree { root in for dir in ["macos-universal", "macos-x86_64"] { let d = root.appendingPathComponent(dir) @@ -51,10 +50,10 @@ struct BinaryResolverTests { } } let r = BinaryResolver(bundledRoot: bundled) - #expect(r.platformDir() == "macos-universal") + XCTAssertEqual(r.platformDir(), "macos-universal") } - @Test func fallsBackToArchDir() throws { + func testFallsBackToArchDir() throws { let bundled = try makeTree { root in let d = root.appendingPathComponent("macos-x86_64") try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) @@ -64,20 +63,20 @@ struct BinaryResolverTests { bundledRoot: bundled, archDirs: ["macos-universal", "macos-x86_64"] ) - #expect(r.platformDir() == "macos-x86_64") + XCTAssertEqual(r.platformDir(), "macos-x86_64") } - @Test func missingEverythingReturnsConstructedPath() throws { + func testMissingEverythingReturnsConstructedPath() throws { let bundled = try makeTree { _ in } let r = BinaryResolver(bundledRoot: bundled) // v1 semantic: path is returned; spawn surfaces the error. - #expect(r.resolve("targen").path.hasSuffix("macos-universal/targen")) - #expect(!r.exists(r.resolve("targen"))) + XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen")) + XCTAssertFalse(r.exists(r.resolve("targen"))) } - @Test func mockAndGamutPaths() throws { + func testMockAndGamutPaths() throws { let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x")) - #expect(r.mock("chartread").path == "/x/mocks/chartread.mock") - #expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam") + XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock") + XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam") } } diff --git a/Tests/ICCeryCoreTests/CGATSParserTests.swift b/Tests/ICCeryCoreTests/CGATSParserTests.swift index a5e9a74..8eb48d4 100644 --- a/Tests/ICCeryCoreTests/CGATSParserTests.swift +++ b/Tests/ICCeryCoreTests/CGATSParserTests.swift @@ -1,9 +1,8 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("CGATS Parser & Writer") -struct CGATSParserTests { +final class CGATSParserTests: XCTestCase { private static let canonicalCTI3 = """ CTI3 @@ -21,44 +20,40 @@ struct CGATSParserTests { END_DATA """ - @Test("Parses CTI3 with canonical field names") - func parseCTI3() throws { + func testParseCTI3() throws { let dataset = try CGATSParser.parse(Self.canonicalCTI3) - #expect(dataset.format == .cti3) - #expect(dataset.samples.count == 2) - #expect(dataset.colorRep == "RGB") - #expect(dataset.deviceClass == "DISPLAY") - #expect(dataset.samples[0].id == "1") - #expect(dataset.samples[0].loc == "A1") - #expect(dataset.samples[1].values["RGB_G"] == "50.0000") + XCTAssertEqual(dataset.format, .cti3) + XCTAssertEqual(dataset.samples.count, 2) + XCTAssertEqual(dataset.colorRep, "RGB") + XCTAssertEqual(dataset.deviceClass, "DISPLAY") + XCTAssertEqual(dataset.samples[0].id, "1") + XCTAssertEqual(dataset.samples[0].loc, "A1") + XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000") } - @Test("Round-trips parse, write, reparse") - func roundTrip() throws { + func testRoundTrip() throws { let first = try CGATSParser.parse(Self.canonicalCTI3) let text = try CGATSWriter.write(first) let second = try CGATSParser.parse(text) - #expect(second.format == first.format) - #expect(second.samples.count == first.samples.count) - #expect(second.colorRep == first.colorRep) - #expect(second.deviceClass == first.deviceClass) + XCTAssertEqual(second.format, first.format) + XCTAssertEqual(second.samples.count, first.samples.count) + XCTAssertEqual(second.colorRep, first.colorRep) + XCTAssertEqual(second.deviceClass, first.deviceClass) } - @Test("Parses CSV with comma delimiters") - func parseCSV() throws { + func testParseCSV() throws { let csv = """ SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B 1,A1,50,0,0,20,10,5,50,60,30 2,A2,0,50,0,10,30,5,60,-50,40 """ let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv")) - #expect(dataset.format == .csv) - #expect(dataset.samples.count == 2) - #expect(dataset.samples[0].values["RGB_R"] == "50.0000") + XCTAssertEqual(dataset.format, .csv) + XCTAssertEqual(dataset.samples.count, 2) + XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000") } - @Test("Converts 0-255 device values to 0-100") - func converts255To100() throws { + func testConverts255To100() throws { let rgb = """ CTI3 COLOR_REP RGB @@ -72,12 +67,11 @@ struct CGATSParserTests { END_DATA """ let dataset = try CGATSParser.parse(rgb) - #expect(dataset.samples[0].values["RGB_R"] == "100.0000") - #expect(dataset.samples[0].values["RGB_G"] == "50.1961") + XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000") + XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961") } - @Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing") - func synthesizesMetadata() throws { + func testSynthesizesMetadata() throws { let cmyk = """ CTI3 NUMBER_OF_FIELDS 6 @@ -90,19 +84,15 @@ struct CGATSParserTests { END_DATA """ let dataset = try CGATSParser.parse(cmyk) - #expect(dataset.colorRep == "CMYK") - #expect(dataset.deviceClass == "PRINTER") + XCTAssertEqual(dataset.colorRep, "CMYK") + XCTAssertEqual(dataset.deviceClass, "PRINTER") } - @Test("Rejects empty file") - func rejectsEmpty() { - #expect(throws: (any Error).self) { - _ = try CGATSParser.parse("") - } + func testRejectsEmpty() { + XCTAssertThrowsError(try CGATSParser.parse("")) } - @Test("Rejects malformed arity") - func rejectsArity() { + func testRejectsArity() { let bad = """ CTI3 NUMBER_OF_FIELDS 2 @@ -114,21 +104,18 @@ struct CGATSParserTests { 1 END_DATA """ - #expect(throws: (any Error).self) { - _ = try CGATSParser.parse(bad) - } + XCTAssertThrowsError(try CGATSParser.parse(bad)) } - @Test("Writer emits valid .ti3 with tabs and required keywords") - func writerFormat() throws { + func testWriterFormat() throws { let dataset = try CGATSParser.parse(Self.canonicalCTI3) let text = try CGATSWriter.write(dataset) - #expect(text.contains("CTI3")) - #expect(text.contains("BEGIN_DATA_FORMAT")) - #expect(text.contains("BEGIN_DATA")) - #expect(text.contains("END_DATA")) - #expect(text.contains("COLOR_REP")) - #expect(text.contains("DEVICE_CLASS")) - #expect(text.contains("\t")) + XCTAssertTrue(text.contains("CTI3")) + XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT")) + XCTAssertTrue(text.contains("BEGIN_DATA")) + XCTAssertTrue(text.contains("END_DATA")) + XCTAssertTrue(text.contains("COLOR_REP")) + XCTAssertTrue(text.contains("DEVICE_CLASS")) + XCTAssertTrue(text.contains("\t")) } } diff --git a/Tests/ICCeryCoreTests/CalibrationIdentityTests.swift b/Tests/ICCeryCoreTests/CalibrationIdentityTests.swift index 4422e87..2f2da4b 100644 --- a/Tests/ICCeryCoreTests/CalibrationIdentityTests.swift +++ b/Tests/ICCeryCoreTests/CalibrationIdentityTests.swift @@ -1,78 +1,67 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore /// Issue #83 — canonical `CAL_` / original-stem pairing. -@Suite("CalibrationIdentity") -struct CalibrationIdentityTests { - @Test("live foo, no persisted") - func livePlain() { +final class CalibrationIdentityTests: XCTestCase { + func testLivePlain() { let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "") - #expect(id.originalBasename == "foo") - #expect(id.calibrationBasename == "CAL_foo") + XCTAssertEqual(id.originalBasename, "foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") } - @Test("live foo ignores stale persisted") - func livePlainIgnoresPersisted() { + func testLivePlainIgnoresPersisted() { let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar") - #expect(id.originalBasename == "foo") - #expect(id.calibrationBasename == "CAL_foo") + XCTAssertEqual(id.originalBasename, "foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") } - @Test("live CAL_foo, persisted foo") - func liveCalPersisted() { + func testLiveCalPersisted() { let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo") - #expect(id.originalBasename == "foo") - #expect(id.calibrationBasename == "CAL_foo") + XCTAssertEqual(id.originalBasename, "foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") } - @Test("live CAL_foo, empty persisted strips prefix") - func liveCalNoPersist() { + func testLiveCalNoPersist() { let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "") - #expect(id.originalBasename == "foo") - #expect(id.calibrationBasename == "CAL_foo") + XCTAssertEqual(id.originalBasename, "foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") } - @Test("persisted original wins over CAL_ live") - func persistedWins() { + func testPersistedWins() { let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar") - #expect(id.originalBasename == "bar") - #expect(id.calibrationBasename == "CAL_bar") + XCTAssertEqual(id.originalBasename, "bar") + XCTAssertEqual(id.calibrationBasename, "CAL_bar") } - @Test("empty live yields empty identity even with persisted original") - func emptyLiveWithPersisted() { + func testEmptyLiveWithPersisted() { let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo") - #expect(id.originalBasename.isEmpty) - #expect(id.calibrationBasename.isEmpty) + XCTAssertTrue(id.originalBasename.isEmpty) + XCTAssertTrue(id.calibrationBasename.isEmpty) } - @Test("empty live, empty persisted") - func emptyLive() { + func testEmptyLive() { let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "") - #expect(id.originalBasename.isEmpty) - #expect(id.calibrationBasename.isEmpty) + XCTAssertTrue(id.originalBasename.isEmpty) + XCTAssertTrue(id.calibrationBasename.isEmpty) } - @Test("prefix is idempotent on already-prefixed input") - func alreadyPrefixed() { - #expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo") - #expect(CalibrationIdentity.prefix("foo") == "CAL_foo") + func testAlreadyPrefixed() { + XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo") + XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo") let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "") - #expect(id.originalBasename == "CAL_foo") - #expect(id.calibrationBasename == "CAL_foo") + XCTAssertEqual(id.originalBasename, "CAL_foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") } - @Test("prefix never invents a name from empty input") - func prefixEmpty() { - #expect(CalibrationIdentity.prefix("").isEmpty) - #expect(CalibrationIdentity.strip("foo") == "foo") - #expect(CalibrationIdentity.strip("CAL_foo") == "foo") + func testPrefixEmpty() { + XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty) + XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo") + XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo") } - @Test("runner process id for a calibration targen is targen_CAL_*") - func processIdMatches() { + func testProcessIdMatches() { let cal = CalibrationIdentity.prefix("foo") - #expect(ProcessID.targen(cal) == "targen_CAL_foo") + XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo") } } diff --git a/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift b/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift index c43313a..1739444 100644 --- a/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift +++ b/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift @@ -1,12 +1,10 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("CalibrationTargenArgs") -struct CalibrationTargenArgsTests { +final class CalibrationTargenArgsTests: XCTestCase { - @Test("RGB baseline") - func rgbBaseline() throws { + func testRgbBaseline() throws { let config = CalibrationTargenConfig( colourSpace: .rgb, steps: 21, @@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests { workingDirectory: URL(fileURLWithPath: "/tmp") ) let args = try CalibrationTargenArgs.build(config: config) - #expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"]) + XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"]) } - @Test("CMYK baseline with ink limit and neutral emphasis") - func cmykWithOptions() throws { + func testCmykWithOptions() throws { let config = CalibrationTargenConfig( colourSpace: .cmyk, steps: 25, @@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests { workingDirectory: URL(fileURLWithPath: "/tmp") ) let args = try CalibrationTargenArgs.build(config: config) - #expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"]) + XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"]) } - @Test("Rejects out-of-range steps") - func rejectsBadSteps() { + func testRejectsBadSteps() { let config = CalibrationTargenConfig(steps: 5, basename: "demo") - #expect(throws: (any Error).self) { - _ = try CalibrationTargenArgs.build(config: config) - } + XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config)) } - @Test("Rejects bad CMYK ink limit") - func rejectsBadInkLimit() { + func testRejectsBadInkLimit() { let config = CalibrationTargenConfig( colourSpace: .cmyk, inkLimit: 500, basename: "demo" ) - #expect(throws: (any Error).self) { - _ = try CalibrationTargenArgs.build(config: config) - } + XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config)) } - @Test("Does not double-prefix an existing CAL_ basename") - func noDoublePrefix() throws { + func testNoDoublePrefix() throws { let config = CalibrationTargenConfig(basename: "CAL_test") let args = try CalibrationTargenArgs.build(config: config) - #expect(args.last == "CAL_test") + XCTAssertEqual(args.last, "CAL_test") } } diff --git a/Tests/ICCeryCoreTests/ColprofArgsTests.swift b/Tests/ICCeryCoreTests/ColprofArgsTests.swift index 9b4f0f7..653e674 100644 --- a/Tests/ICCeryCoreTests/ColprofArgsTests.swift +++ b/Tests/ICCeryCoreTests/ColprofArgsTests.swift @@ -1,72 +1,63 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("ColprofArgs") -struct ColprofArgsTests { +final class ColprofArgsTests: XCTestCase { - @Test("Default algorithm and quality") - func defaults() throws { + func testDefaults() throws { let config = ColprofConfig(basename: "target") let args = try ColprofArgs.build(config: config) - #expect(args == ["-v", "-a", "l", "-q", "m", "target"]) + XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"]) } - @Test("FWA bare -f when empty string") - func fwaBareFlag() throws { + func testFwaBareFlag() throws { let config = ColprofConfig(fwa: "", basename: "target") let args = try ColprofArgs.build(config: config) - #expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"]) + XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"]) } - @Test("FWA D50 and D65 emit -f value") - func fwaD50() throws { + func testFwaD50() throws { let config = ColprofConfig(fwa: "D50", basename: "target") let args = try ColprofArgs.build(config: config) - #expect(args.contains("-f")) - #expect(args.contains("D50")) - #expect(args.last == "target") + XCTAssertTrue(args.contains("-f")) + XCTAssertTrue(args.contains("D50")) + XCTAssertEqual(args.last, "target") } - @Test("FWA none is omitted") - func fwaNoneOmitted() throws { + func testFwaNoneOmitted() throws { let config = ColprofConfig(fwa: "none", basename: "target") let args = try ColprofArgs.build(config: config) - #expect(!args.contains("-f")) + XCTAssertFalse(args.contains("-f")) } - @Test("Viewing conditions skip none") - func viewingCondNoneSkipped() throws { + func testViewingCondNoneSkipped() throws { let config = ColprofConfig( inputViewingCond: "none", outputViewingCond: "mt", basename: "target" ) let args = try ColprofArgs.build(config: config) - #expect(!args.contains("-c")) - #expect(args.contains("-d")) - #expect(args.contains("mt")) + XCTAssertFalse(args.contains("-c")) + XCTAssertTrue(args.contains("-d")) + XCTAssertTrue(args.contains("mt")) } - @Test("Description falls back to basename when empty") - func descriptionFallback() throws { + func testDescriptionFallback() throws { let config = ColprofConfig(description: "", basename: "target") let args = try ColprofArgs.build(config: config) - #expect(!args.contains("-D")) + XCTAssertFalse(args.contains("-D")) } - @Test("Copyright only when non-empty") - func copyright() throws { + func testCopyright() throws { let config = ColprofConfig(copyright: "Gronod 2026", basename: "target") let args = try ColprofArgs.build(config: config) - #expect(args.contains("-C")) - #expect(args.contains("Gronod 2026")) + XCTAssertTrue(args.contains("-C")) + XCTAssertTrue(args.contains("Gronod 2026")) } - @Test("No -u passed") - func noProgressJsonFlag() throws { + func testNoProgressJsonFlag() throws { let config = ColprofConfig(basename: "target") let args = try ColprofArgs.build(config: config) - #expect(!args.contains("-u")) + XCTAssertFalse(args.contains("-u")) } } diff --git a/Tests/ICCeryCoreTests/ColprofProgressTests.swift b/Tests/ICCeryCoreTests/ColprofProgressTests.swift index 922cf53..66f7b31 100644 --- a/Tests/ICCeryCoreTests/ColprofProgressTests.swift +++ b/Tests/ICCeryCoreTests/ColprofProgressTests.swift @@ -1,24 +1,20 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("ColprofProgress") -struct ColprofProgressTests { +final class ColprofProgressTests: XCTestCase { - @Test("Classifies gamut mapping") - func gamutMapping() { - #expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping) + func testGamutMapping() { + XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping) } - @Test("Classifies fitting or clut") - func fitting() { - #expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut) - #expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut) + func testFitting() { + XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut) + XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut) } - @Test("Classifies writing") - func writing() { - #expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc) - #expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc) + func testWriting() { + XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc) + XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc) } } diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift index 52498d3..0f035da 100644 --- a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -1,4 +1,4 @@ -import Testing +import XCTest import Foundation @testable import ICCeryCore @testable import ICCery @@ -6,48 +6,42 @@ import AppKit import ApplicationServices /// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥). -@Suite("CupsOptionsFilter") -struct CupsOptionsFilterTests { +final class CupsOptionsFilterTests: XCTestCase { - @Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys") - func dropsReserved() { + func testDropsReserved() { let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching " + "AP.ColorMatchingMode=AP_ApplicationColorMatching " + "com.apple.print.JobTicket.PMTotalSidesImaged=0 " + "collate=true copies=1 job-sheets=none,none " + "pserrorhandler-requested=standard " + "MediaType=PhotographicGlossy" - #expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy") + XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy") } - @Test("Keeps relevant driver keys, order preserved") - func keepsRelevant() { + func testKeepsRelevant() { let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 " + "Resolution=600x600dpi Duplex=None" - #expect(CupsOptionsFilter.filter(raw) == raw) + XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) } - @Test("Permissive: unknown non-com.* keys survive") - func keepsUnknown() { + func testKeepsUnknown() { let raw = "VendorFooBar=baz MediaType=Plain" - #expect(CupsOptionsFilter.filter(raw) == raw) + XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) } - @Test("Drops empty keys and values") - func dropsEmpty() { + func testDropsEmpty() { let raw = "=noval MediaType= InputSlot=Rear" // "MediaType=" has an empty value → dropped; "=noval" empty key. - #expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear") + XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear") } - @Test("extractMediaType prefers MediaType then EPIJ_Medi") - func extractMedia() { - #expect(CupsParsers.extractMediaType( - fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo") - #expect(CupsParsers.extractMediaType( - fromOptionsString: "EPIJ_Medi=7") == "7") - #expect(CupsParsers.extractMediaType( - fromOptionsString: "PageSize=A4") == nil) + func testExtractMedia() { + XCTAssertEqual(CupsParsers.extractMediaType( + fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo") + XCTAssertEqual(CupsParsers.extractMediaType( + fromOptionsString: "EPIJ_Medi=7"), "7") + XCTAssertNil(CupsParsers.extractMediaType( + fromOptionsString: "PageSize=A4")) } } @@ -55,9 +49,8 @@ struct CupsOptionsFilterTests { /// `@convention(c)` closures can't capture, so recording goes through /// a file-scope recorder keyed by global state; no private symbols are /// touched. -@Suite("ColorSyncSuppressor") @MainActor -struct ColorSyncSuppressorTests { +final class ColorSyncSuppressorTests: XCTestCase { /// Fake PMPrintSession — the injected resolver never dereferences it. private var fakeSession: PMPrintSession { @@ -90,55 +83,50 @@ struct ColorSyncSuppressorTests { return s } - @Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first") - func attemptOrder() { + func testAttemptOrder() { Self.recorded = [] Self.succeeding = nil Self.missing = ["PMSessionSetColorMatchingModeLock"] let s = makeSuppressor() - #expect(s.applySPIMode(to: fakeSession) == false) + XCTAssertEqual(s.applySPIMode(to: fakeSession), false) // Lock is unresolvable → skipped; the rest plays out in order. - #expect(Self.recorded.map { "\($0.0)|\($0.1)" } - == ColorMatchingAttempts.attempts + XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts .filter { $0.symbol != "PMSessionSetColorMatchingModeLock" } .map { "\($0.symbol)|\($0.mode)" }) } - @Test("First zero wins — later symbols/modes not called") - func firstZeroWins() { + func testFirstZeroWins() { Self.recorded = [] Self.succeeding = ("PMSessionSetColorMatchingModeLock", "AP_ApplicationColorMatching") Self.missing = [] let s = makeSuppressor() - #expect(s.applySPIMode(to: fakeSession)) - #expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [ + XCTAssertTrue(s.applySPIMode(to: fakeSession)) + XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [ "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", ]) } - @Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried") - func modeFallback() { + func testModeFallback() { Self.recorded = [] Self.succeeding = ("PMSessionSetColorMatchingModeLock", "ApplicationColorMatching") Self.missing = [] let s = makeSuppressor() - #expect(s.applySPIMode(to: fakeSession)) - #expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock") - #expect(Self.recorded[0].1 == "AP_ApplicationColorMatching") - #expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock") - #expect(Self.recorded[1].1 == "ApplicationColorMatching") - #expect(Self.recorded.count == 2) + XCTAssertTrue(s.applySPIMode(to: fakeSession)) + XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock") + XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching") + XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock") + XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching") + XCTAssertEqual(Self.recorded.count, 2) } - @Test("All symbols missing → false, no calls") - func allMissing() { + func testAllMissing() { Self.recorded = [] Self.succeeding = nil Self.missing = Set(ColorMatchingAttempts.symbols) let s = makeSuppressor() - #expect(s.applySPIMode(to: fakeSession) == false) - #expect(Self.recorded.isEmpty) + XCTAssertEqual(s.applySPIMode(to: fakeSession), false) + XCTAssertTrue(Self.recorded.isEmpty) } } diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index fdf948f..a4be264 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -1,11 +1,10 @@ -import Testing +import XCTest import Foundation @testable import ICCeryCore /// Issue 12 — CUPS enumeration parsers on recorded fixtures /// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here. -@Suite("CupsParsers") -struct CupsParsersTests { +final class CupsParsersTests: XCTestCase { // Recorded on an Epson XP-55 + Canon Pro9500 host. private let lpstatE = """ @@ -34,112 +33,102 @@ struct CupsParsersTests { cupsPrintQuality/cupsPrintQuality: Draft *Normal High """ - @Test("lpstat -e: one destination per line; empty = success") - func destinations() { - #expect(CupsParsers.lpstatDestinations(lpstatE) == [ + func testDestinations() { + XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [ "Canon_Pro9500_II_series_XPS", "Epson_XP_55_LPD", "EPSON_XP_55_Series", ]) - #expect(CupsParsers.lpstatDestinations("") == []) + XCTAssertEqual(CupsParsers.lpstatDestinations(""), []) } - @Test("lpstat -p: idle / now-printing / disabled statuses") - func statuses() { + func testStatuses() { let s = CupsParsers.lpstatStatuses(lpstatP) - #expect(s["Canon_Pro9500_II_series_XPS"] == .idle) - #expect(s["Epson_XP_55_LPD"] == .printing) - #expect(s["EPSON_XP_55_Series"] == .stopped) + XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle) + XCTAssertEqual(s["Epson_XP_55_LPD"], .printing) + XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped) } - @Test("lpstat -d: default destination or none") - func defaultDestination() { - #expect(CupsParsers.lpstatDefault( - "system default destination: Canon_Pro9500_II_series_XPS\n") - == "Canon_Pro9500_II_series_XPS") - #expect(CupsParsers.lpstatDefault("no system default destination\n") == nil) + func testDefaultDestination() { + XCTAssertEqual(CupsParsers.lpstatDefault( + "system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS") + XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n")) } - @Test("lpoptions -p: quoted printer-info, bare flags ignored") - func displayName() { - #expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series") - #expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil) + func testDisplayName() { + XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series") + XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n")) } - @Test("lpoptions -l: key/label split, * marks the default") - func optionListings() { + func testOptionListings() { let listings = CupsParsers.lpoptionsList(lpoptionsL) - #expect(listings.count == 6) + XCTAssertEqual(listings.count, 6) let page = listings[0] - #expect(page.key == "PageSize") - #expect(page.label == "Media Size") - #expect(page.defaultChoice == "A4") - #expect(page.choices.contains("Custom.WIDTHxHEIGHT")) - #expect(!page.choices.contains("*A4")) + XCTAssertEqual(page.key, "PageSize") + XCTAssertEqual(page.label, "Media Size") + XCTAssertEqual(page.defaultChoice, "A4") + XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT")) + XCTAssertFalse(page.choices.contains("*A4")) let slot = listings[1] - #expect(slot.key == "InputSlot") - #expect(slot.choices == ["Auto", "Main", "Photo", "Rear"]) - #expect(slot.defaultChoice == "Main") + XCTAssertEqual(slot.key, "InputSlot") + XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"]) + XCTAssertEqual(slot.defaultChoice, "Main") } - @Test("capabilities: trays/sizes index 1-based, media uses detected key") - func capabilities() { + func testCapabilities() { let service = CupsService() let listings = CupsParsers.lpoptionsList(lpoptionsL) let caps = service.capabilities(from: listings, ppd: nil) - #expect(caps.trays == [ + XCTAssertEqual(caps.trays, [ PrinterTray(id: 1, name: "Auto"), PrinterTray(id: 2, name: "Main"), PrinterTray(id: 3, name: "Photo"), PrinterTray(id: 4, name: "Rear"), ]) - #expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5")) - #expect(caps.paperSizes.count == 10) - #expect(caps.mediaTypes.map(\.id) == [ + XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5")) + XCTAssertEqual(caps.paperSizes.count, 10) + XCTAssertEqual(caps.mediaTypes.map(\.id), [ "Stationery", "PhotographicHighGloss", "Photographic", "PhotographicMatte", "Envelope", ]) - #expect(caps.supportsOrientation) + XCTAssertTrue(caps.supportsOrientation) } - @Test("PPD enrichment maps id → human label") - func ppdLabels() { + func testPpdLabels() { let ppd = """ *CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<>" *CNIJMediaType 0/Plain Paper: "" *en_US.CNIJMediaType 13/Envelope: "" """ let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") - #expect(labels["42"] == "Photo Paper Plus Semi-gloss") - #expect(labels["0"] == "Plain Paper") - #expect(labels["13"] == "Envelope") + XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss") + XCTAssertEqual(labels["0"], "Plain Paper") + XCTAssertEqual(labels["13"], "Envelope") } - @Test("detectMediaTypeKey prefers vendor keys in order") - func mediaTypeKey() { - #expect(CupsParsers.detectMediaTypeKey( - optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType") - #expect(CupsParsers.detectMediaTypeKey( - optionKeys: ["PageSize", "MediaType"]) == "MediaType") - #expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil) + func testMediaTypeKey() { + XCTAssertEqual(CupsParsers.detectMediaTypeKey( + optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType") + XCTAssertEqual(CupsParsers.detectMediaTypeKey( + optionKeys: ["PageSize", "MediaType"]), "MediaType") + XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"])) } - @Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat") - func driverBypass() { + func testDriverBypass() { func pair(_ keys: Set) -> String? { CupsParsers.detectDriverColorBypass(optionKeys: keys) .map { "\($0.key)=\($0.value)" } } - #expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4") - #expect(pair(["CNIJIntent"]) == "CNIJIntent=4") - #expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0") - #expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3") - #expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected") - #expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected") - #expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off") - #expect(pair(["PageSize"]) == nil) + XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4") + XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4") + XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0") + XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3") + XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected") + XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected") + XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off") + XCTAssertNil(pair(["PageSize"])) } } diff --git a/Tests/ICCeryCoreTests/DriftAlertTests.swift b/Tests/ICCeryCoreTests/DriftAlertTests.swift index 4eb2835..11fb145 100644 --- a/Tests/ICCeryCoreTests/DriftAlertTests.swift +++ b/Tests/ICCeryCoreTests/DriftAlertTests.swift @@ -1,65 +1,57 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("DriftAlert") -struct DriftAlertTests { +final class DriftAlertTests: XCTestCase { - @Test("No alert with fewer than two poor results") - func notEnough() { + func testNotEnough() { let records = [ record(avg: 4.0, at: 1000) ] - #expect(DriftAlert.compute(from: records) == nil) + XCTAssertNil(DriftAlert.compute(from: records)) } - @Test("Alert on two poor results one hour apart") - func oneHourApart() { + func testOneHourApart() { let records = [ record(avg: 4.0, at: 1000), record(avg: 5.0, at: 4600) ] - #expect(DriftAlert.compute(from: records) != nil) + XCTAssertNotNil(DriftAlert.compute(from: records)) } - @Test("No alert if same day and under one hour") - func sameDayUnderHour() { + func testSameDayUnderHour() { let records = [ record(avg: 4.0, at: 1000), record(avg: 5.0, at: 2000) ] - #expect(DriftAlert.compute(from: records) == nil) + XCTAssertNil(DriftAlert.compute(from: records)) } - @Test("Alert on distinct days") - func distinctDays() { + func testDistinctDays() { let day1 = record(avg: 4.0, at: 0) let day2 = record(avg: 5.0, at: 86400 + 1000) - #expect(DriftAlert.compute(from: [day1, day2]) != nil) + XCTAssertNotNil(DriftAlert.compute(from: [day1, day2])) } - @Test("Non-poor records do not trigger") - func nonPoor() { + func testNonPoor() { let records = [ record(avg: 1.0, at: 0), record(avg: 1.5, at: 86400) ] - #expect(DriftAlert.compute(from: records) == nil) + XCTAssertNil(DriftAlert.compute(from: records)) } - @Test("Non-poor records break the consecutive poor run") - func nonPoorBreaksRun() { + func testNonPoorBreaksRun() { let records = [ record(avg: 4.0, at: 0), // poor record(avg: 4.5, at: 86400), // poor, far apart record(avg: 1.0, at: 90000), // good — breaks the run record(avg: 4.0, at: 92000) // poor, recent but close to previous poor ] - #expect(DriftAlert.compute(from: records) == nil) + XCTAssertNil(DriftAlert.compute(from: records)) } - @Test("Only the final consecutive poor run is considered") - func onlySuffixRun() { + func testOnlySuffixRun() { let records = [ record(avg: 4.0, at: 0), // poor record(avg: 4.5, at: 18000), // poor, > 1h from first @@ -67,26 +59,24 @@ struct DriftAlertTests { record(avg: 4.0, at: 25000), // poor record(avg: 4.5, at: 26000) // poor, < 1h and same day ] - #expect(DriftAlert.compute(from: records) == nil) + XCTAssertNil(DriftAlert.compute(from: records)) } - @Test("Final consecutive poor run alerts when far apart") - func suffixRunAlerts() { + func testSuffixRunAlerts() { let records = [ record(avg: 1.0, at: 0), // good record(avg: 4.0, at: 1000), // poor record(avg: 4.5, at: 4600) // poor, 1h after previous ] - #expect(DriftAlert.compute(from: records) != nil) + XCTAssertNotNil(DriftAlert.compute(from: records)) } - @Test("A single final poor record after good records does not alert") - func singleFinalPoor() { + func testSingleFinalPoor() { let records = [ record(avg: 1.0, at: 0), record(avg: 4.0, at: 86400) ] - #expect(DriftAlert.compute(from: records) == nil) + XCTAssertNil(DriftAlert.compute(from: records)) } private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord { diff --git a/Tests/ICCeryCoreTests/FileHelpersTests.swift b/Tests/ICCeryCoreTests/FileHelpersTests.swift index 0854f8c..32a99a8 100644 --- a/Tests/ICCeryCoreTests/FileHelpersTests.swift +++ b/Tests/ICCeryCoreTests/FileHelpersTests.swift @@ -1,4 +1,4 @@ -import Testing +import XCTest import Foundation @testable import ICCeryCore @@ -13,91 +13,88 @@ private func touch(_ url: URL, _ contents: String = "x") throws { try contents.write(to: url, atomically: true, encoding: .utf8) } -@Suite("PathSecurity") -struct PathSecurityTests { - @Test func rejectsTraversalAndSeparators() { +final class PathSecurityTests: XCTestCase { + func testRejectsTraversalAndSeparators() { for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] { - #expect(!PathSecurity.isValidBasename(bad)) - #expect(throws: PathSecurity.Error.self) { - try PathSecurity.sanitizeBasename(bad) + XCTAssertFalse(PathSecurity.isValidBasename(bad)) + XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in + XCTAssertTrue(error is PathSecurity.Error) } } } - @Test func acceptsNormalNames() { + func testAcceptsNormalNames() { for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] { - #expect(PathSecurity.isValidBasename(good)) + XCTAssertTrue(PathSecurity.isValidBasename(good)) } } - @Test func resolveSafeCwdPrefersExplicit() throws { + func testResolveSafeCwdPrefersExplicit() throws { let dir = try tempDir() - #expect(PathSecurity.resolveSafeCwd(dir) == dir) + XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir) } - @Test func resolveSafeCwdNeverReturnsNil() { + func testResolveSafeCwdNeverReturnsNil() { let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)") let resolved = PathSecurity.resolveSafeCwd(missing) - #expect(FileManager.default.fileExists(atPath: resolved.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path)) } } -@Suite("AtomicFileWriter") -struct AtomicFileWriterTests { - @Test func writesAndLeavesNoTmp() throws { +final class AtomicFileWriterTests: XCTestCase { + func testWritesAndLeavesNoTmp() throws { let dir = try tempDir() let url = dir.appendingPathComponent("state.json") try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url) - #expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}") - #expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path)) + XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}") + XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path)) } - @Test func overwritesExistingAtomically() throws { + func testOverwritesExistingAtomically() throws { let dir = try tempDir() let url = dir.appendingPathComponent("f.txt") try AtomicFileWriter.write("one", to: url) try AtomicFileWriter.write("two-longer", to: url) - #expect(try String(contentsOf: url, encoding: .utf8) == "two-longer") + XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer") } - @Test func createsParentDirs() throws { + func testCreatesParentDirs() throws { let dir = try tempDir() let url = dir.appendingPathComponent("a/b/c/deep.json") try AtomicFileWriter.write("{}", to: url) - #expect(FileManager.default.fileExists(atPath: url.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) } } -@Suite("ArtefactProbe") -struct ArtefactProbeTests { - @Test func verifyProgression() throws { +final class ArtefactProbeTests: XCTestCase { + func testVerifyProgression() throws { let dir = try tempDir() var v = ArtefactProbe.verify(basename: "t", cwd: dir) - #expect(v == StageArtefacts()) + XCTAssertEqual(v, StageArtefacts()) try touch(dir.appendingPathComponent("t.ti1")) v = ArtefactProbe.verify(basename: "t", cwd: dir) - #expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete) + XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete) try touch(dir.appendingPathComponent("t.ti2")) try touch(dir.appendingPathComponent("t.ti3")) v = ArtefactProbe.verify(basename: "t", cwd: dir) - #expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete) + XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete) try touch(dir.appendingPathComponent("t.icc")) v = ArtefactProbe.verify(basename: "t", cwd: dir) - #expect(v.stage4Complete && v.profilePath?.pathExtension == "icc") + XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc") } - @Test func icmWinsOverIcc() throws { + func testIcmWinsOverIcc() throws { let dir = try tempDir() try touch(dir.appendingPathComponent("p.icc")) try touch(dir.appendingPathComponent("p.icm")) let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir) - #expect(profile?.pathExtension == "icm") + XCTAssertEqual(profile?.pathExtension, "icm") } - @Test func enumeratesPassesPagesAndCAL() throws { + func testEnumeratesPassesPagesAndCAL() throws { let dir = try tempDir() for name in [ "t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif", @@ -115,15 +112,15 @@ struct ArtefactProbeTests { "t.ti3", "t_pass1.ti3", "t_pass2.ti3", "t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal", ] { - #expect(names.contains(expected), "missing \(expected)") + XCTAssertTrue(names.contains(expected), "missing \(expected)") } - #expect(!names.contains("other.ti1")) - #expect(!names.contains("t.txt")) - #expect(!names.contains("CAL_other.ti1")) + XCTAssertFalse(names.contains("other.ti1")) + XCTAssertFalse(names.contains("t.txt")) + XCTAssertFalse(names.contains("CAL_other.ti1")) } - @Test func emptyDirReturnsEmpty() throws { + func testEmptyDirReturnsEmpty() throws { let dir = try tempDir() - #expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty) + XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty) } } diff --git a/Tests/ICCeryCoreTests/GamutGeometryBuilderTests.swift b/Tests/ICCeryCoreTests/GamutGeometryBuilderTests.swift index 5802421..d3f1b31 100644 --- a/Tests/ICCeryCoreTests/GamutGeometryBuilderTests.swift +++ b/Tests/ICCeryCoreTests/GamutGeometryBuilderTests.swift @@ -1,15 +1,13 @@ -import Testing +import XCTest import SceneKit import ICCeryCore @testable import ICCery /// ``GamutSceneGeometryBuilder`` edge-case tests. -@Suite("Gamut scene geometry builder") @MainActor -struct GamutGeometryBuilderTests { +final class GamutGeometryBuilderTests: XCTestCase { - @Test("Drops out-of-bounds faces from the element without crashing") - func dropsOutOfBoundsFaces() { + func testDropsOutOfBoundsFaces() { let white = GamutVertex( lab: LabColor(l: 100, a: 0, b: 0), rgb: DisplayRGB(r: 1, g: 1, b: 1) @@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests { let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh) - #expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer") + XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer") } } diff --git a/Tests/ICCeryCoreTests/GamutMeshParserTests.swift b/Tests/ICCeryCoreTests/GamutMeshParserTests.swift index c3643a2..cb54693 100644 --- a/Tests/ICCeryCoreTests/GamutMeshParserTests.swift +++ b/Tests/ICCeryCoreTests/GamutMeshParserTests.swift @@ -1,10 +1,9 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore /// ``GamutMeshParser`` acceptance + edge-case tests. -@Suite("Gamut mesh parser") -struct GamutMeshParserTests { +final class GamutMeshParserTests: XCTestCase { /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. private var bundledSRGBGamURL: URL { @@ -13,16 +12,14 @@ struct GamutMeshParserTests { return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") } - @Test("Parses bundled sRGB.gam") - func parsesBundledSRGB() throws { + func testParsesBundledSRGB() throws { let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL) - #expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices") - #expect(mesh.faces.count == 892, "sRGB.gam has 892 faces") + XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices") + XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces") } - @Test("Discards VERTEX_NO and uses push-order indices") - func discardsVertexNo() throws { + func testDiscardsVertexNo() throws { let text = """ GAMUT NUMBER_OF_FIELDS 4 @@ -49,14 +46,13 @@ struct GamutMeshParserTests { let mesh = try GamutMeshParser.parse(text: text) - #expect(mesh.vertices.count == 4) - #expect(mesh.faces.count == 2) - #expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30)) - #expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60)) + XCTAssertEqual(mesh.vertices.count, 4) + XCTAssertEqual(mesh.faces.count, 2) + XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30)) + XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60)) } - @Test("Ignores comments and blank lines") - func ignoresComments() throws { + func testIgnoresComments() throws { let text = """ # Header comment NUMBER_OF_FIELDS 4 @@ -81,12 +77,11 @@ struct GamutMeshParserTests { """ let mesh = try GamutMeshParser.parse(text: text) - #expect(mesh.vertices.count == 2) - #expect(mesh.faces.count == 1) + XCTAssertEqual(mesh.vertices.count, 2) + XCTAssertEqual(mesh.faces.count, 1) } - @Test("Remaps coordinates to x=a*, y=L*, z=b*") - func remapsCoordinates() throws { + func testRemapsCoordinates() throws { let text = """ NUMBER_OF_FIELDS 4 BEGIN_DATA_FORMAT @@ -99,11 +94,10 @@ struct GamutMeshParserTests { """ let mesh = try GamutMeshParser.parse(text: text) - #expect(mesh.vertices.first?.position == SIMD3(-20, 50, 80)) + XCTAssertEqual(mesh.vertices.first?.position, SIMD3(-20, 50, 80)) } - @Test("Computes per-vertex sRGB colour") - func computesVertexColor() throws { + func testComputesVertexColor() throws { let text = """ NUMBER_OF_FIELDS 4 BEGIN_DATA_FORMAT @@ -116,14 +110,13 @@ struct GamutMeshParserTests { """ let mesh = try GamutMeshParser.parse(text: text) - let white = try #require(mesh.vertices.first).rgb - #expect(white.r > 0.95) - #expect(white.g > 0.95) - #expect(white.b > 0.95) + let white = try XCTUnwrap(mesh.vertices.first).rgb + XCTAssertTrue(white.r > 0.95) + XCTAssertTrue(white.g > 0.95) + XCTAssertTrue(white.b > 0.95) } - @Test("Drops out-of-bounds face indices") - func dropsOutOfBoundsFaces() throws { + func testDropsOutOfBoundsFaces() throws { let text = """ NUMBER_OF_FIELDS 4 BEGIN_DATA_FORMAT @@ -146,21 +139,23 @@ struct GamutMeshParserTests { """ let mesh = try GamutMeshParser.parse(text: text) - #expect(mesh.faces.count == 1) + XCTAssertEqual(mesh.faces.count, 1) } - @Test("Throws on empty file") - func throwsOnEmptyFile() { - #expect(throws: GamutMeshParseError.noDataBlock) { - _ = try GamutMeshParser.parse(text: "") + func testThrowsOnEmptyFile() { + XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in + guard case GamutMeshParseError.noDataBlock = error else { + return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)") + } } } - @Test("Throws when file is missing") - func throwsWhenMissing() { + func testThrowsWhenMissing() { let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam") - #expect(throws: GamutMeshParseError.missingFile) { - _ = try GamutMeshParser.parse(url: url) + XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in + guard case GamutMeshParseError.missingFile = error else { + return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)") + } } } } diff --git a/Tests/ICCeryCoreTests/IccgamutArgsTests.swift b/Tests/ICCeryCoreTests/IccgamutArgsTests.swift index 2ef2ba9..86916a9 100644 --- a/Tests/ICCeryCoreTests/IccgamutArgsTests.swift +++ b/Tests/ICCeryCoreTests/IccgamutArgsTests.swift @@ -1,16 +1,14 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("IccgamutArgs") -struct IccgamutArgsTests { +final class IccgamutArgsTests: XCTestCase { - @Test("Density is 10 and not a directory") - func densityNotDirectory() throws { + func testDensityNotDirectory() throws { let config = IccgamutConfig( profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc") ) let args = try IccgamutArgs.build(config: config) - #expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"]) + XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"]) } } diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift index 4dcb22a..893aa96 100644 --- a/Tests/ICCeryCoreTests/LpArgsTests.swift +++ b/Tests/ICCeryCoreTests/LpArgsTests.swift @@ -1,12 +1,11 @@ -import Testing +import XCTest import Foundation @testable import ICCeryCore /// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`). /// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover /// flag order, captured-option precedence, and sanitisation. -@Suite("LpArgs") -struct LpArgsTests { +final class LpArgsTests: XCTestCase { private let tiff = "/tmp/work/target_001.tif" private let queue = "EPSON_XP_55_Series" @@ -20,112 +19,100 @@ struct LpArgsTests { options: options, optionKeys: optionKeys) } - @Test("Header: -d queue -t title, both AP_* first, TIFF last") - func header() throws { + func testHeader() throws { let argv = try build() - #expect(Array(argv[0...1]) == ["-d", queue]) - #expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"]) - #expect(Array(argv[4...5]) - == ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) - #expect(Array(argv[6...7]) - == ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) - #expect(argv.last == tiff) - #expect(!argv.contains { $0 == "raw" || $0 == "-o raw" }) + XCTAssertEqual(Array(argv[0...1]), ["-d", queue]) + XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"]) + XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) + XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) + XCTAssertEqual(argv.last, tiff) + XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" }) } - @Test("Never emits -o raw; captured raw= is dropped") - func neverRaw() throws { + func testNeverRaw() throws { let argv = try build(options: PrintOptions( cupsOptions: "raw=true MediaType=Photo")) for (i, arg) in argv.enumerated() where arg == "-o" { - #expect(argv[i + 1] != "raw") - #expect(argv[i + 1] != "raw=true") + XCTAssertNotEqual(argv[i + 1], "raw") + XCTAssertNotEqual(argv[i + 1], "raw=true") } - #expect(!argv.contains { $0.hasPrefix("raw=") }) - #expect(argv.contains("MediaType=Photo")) + XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") }) + XCTAssertTrue(argv.contains("MediaType=Photo")) } - @Test("Captured options replayed after AP_* headers") - func capturedReplay() throws { + func testCapturedReplay() throws { let argv = try build(options: PrintOptions( cupsOptions: "InputSlot=Rear MediaType=Photo")) let rear = argv.firstIndex(of: "InputSlot=Rear")! let apFirst = argv.firstIndex(of: "AP_ColorMatchingMode=AP_ApplicationColorMatching")! - #expect(rear > apFirst) + XCTAssertTrue(rear > apFirst) } - @Test("Captured wins: media key present → derived media skipped") - func capturedWinsMedia() throws { + func testCapturedWinsMedia() throws { let argv = try build( options: PrintOptions( mediaType: "Plain", cupsOptions: "MediaType=Glossy"), optionKeys: ["MediaType"]) - #expect(argv.contains("MediaType=Glossy")) - #expect(!argv.contains("MediaType=Plain")) + XCTAssertTrue(argv.contains("MediaType=Glossy")) + XCTAssertFalse(argv.contains("MediaType=Plain")) } - @Test("Media emitted via detected key when not captured") - func mediaDerived() throws { + func testMediaDerived() throws { let argv = try build( options: PrintOptions(mediaType: "SemiGloss"), optionKeys: ["CNIJMediaType", "MediaType"]) // CNIJMediaType wins over MediaType in detection order. - #expect(argv.contains("CNIJMediaType=SemiGloss")) - #expect(!argv.contains("MediaType=SemiGloss")) + XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss")) + XCTAssertFalse(argv.contains("MediaType=SemiGloss")) } - @Test("Driver bypass emitted when absent, skipped when captured") - func bypassRules() throws { + func testBypassRules() throws { let withBypass = try build( optionKeys: ["EPIJ_CMat"]) - #expect(withBypass.contains("EPIJ_CMat=3")) + XCTAssertTrue(withBypass.contains("EPIJ_CMat=3")) let captured = try build( options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), optionKeys: ["EPIJ_CMat"]) // Captured value kept, detection not re-applied. - #expect(captured.filter { $0.hasPrefix("EPIJ_CMat") } - == ["EPIJ_CMat=1"]) + XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"]) } - @Test("Orientation: portrait=3 landscape=4; captured wins") - func orientation() throws { - #expect(try build(options: PrintOptions(orientation: "portrait")) + func testOrientation() throws { + XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait")) .contains("orientation-requested=3")) - #expect(try build(options: PrintOptions(orientation: "landscape")) + XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape")) .contains("orientation-requested=4")) let capturedOrients = try build(options: PrintOptions( orientation: "landscape", cupsOptions: "orientation-requested=5")) - #expect(!capturedOrients.contains("orientation-requested=4")) - #expect(capturedOrients.contains("orientation-requested=5")) + XCTAssertFalse(capturedOrients.contains("orientation-requested=4")) + XCTAssertTrue(capturedOrients.contains("orientation-requested=5")) } - @Test("PageSize emitted unless captured") - func pageSize() throws { - #expect(try build(options: PrintOptions(paperSize: "A4")) + func testPageSize() throws { + XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4")) .contains("PageSize=A4")) let capturedSize = try build(options: PrintOptions( paperSize: "A4", cupsOptions: "PageSize=Letter")) - #expect(!capturedSize.contains("PageSize=A4")) - #expect(capturedSize.contains("PageSize=Letter")) + XCTAssertFalse(capturedSize.contains("PageSize=A4")) + XCTAssertTrue(capturedSize.contains("PageSize=Letter")) } - @Test("Sanitise rejects `;`, newline, and shell metachars") - func sanitise() throws { - #expect(throws: LpArgsError.self) { - _ = try build(options: PrintOptions( - cupsOptions: "InputSlot=Rear;rm -rf /")) + func testSanitise() throws { + XCTAssertThrowsError(try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in + XCTAssertTrue(error is LpArgsError) } - #expect(throws: LpArgsError.self) { - _ = try build(options: PrintOptions( - cupsOptions: "InputSlot=Rear\nMediaType=Photo")) + XCTAssertThrowsError(try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in + XCTAssertTrue(error is LpArgsError) } - #expect(throws: LpArgsError.self) { - _ = try build(options: PrintOptions( - cupsOptions: "InputSlot=$(whoami)")) + XCTAssertThrowsError(try build(options: PrintOptions( + cupsOptions: "InputSlot=$(whoami)"))) { error in + XCTAssertTrue(error is LpArgsError) } } } diff --git a/Tests/ICCeryCoreTests/MeasurementTests.swift b/Tests/ICCeryCoreTests/MeasurementTests.swift index 3687237..aac8cdb 100644 --- a/Tests/ICCeryCoreTests/MeasurementTests.swift +++ b/Tests/ICCeryCoreTests/MeasurementTests.swift @@ -1,12 +1,10 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("InstrumentParser") -struct InstrumentParserTests { +final class InstrumentParserTests: XCTestCase { - @Test("Parses pretty-printed instlist JSON") - func json() throws { + func testJson() throws { let json = """ { "event": "instruments", @@ -18,134 +16,118 @@ struct InstrumentParserTests { } """ let devices = try InstrumentParser.parse(json) - #expect(devices.count == 3) - #expect(devices[0].port == 1) - #expect(devices[0].name == "X-Rite i1Pro") - #expect(devices[2].port == 3) + XCTAssertEqual(devices.count, 3) + XCTAssertEqual(devices[0].port, 1) + XCTAssertEqual(devices[0].name, "X-Rite i1Pro") + XCTAssertEqual(devices[2].port, 3) } - @Test("Falls back to regex for legacy instlist text") - func regexFallback() throws { + func testRegexFallback() throws { let text = """ 1: 'X-Rite i1Pro' on usb 2: 'ColorMunki Smile' """ + "\n" let devices = try InstrumentParser.parse(text) - #expect(devices.count == 2) - #expect(devices[0].port == 1) - #expect(devices[1].name == "ColorMunki Smile") + XCTAssertEqual(devices.count, 2) + XCTAssertEqual(devices[0].port, 1) + XCTAssertEqual(devices[1].name, "ColorMunki Smile") } - @Test("Empty output returns no devices") - func empty() throws { - #expect(try InstrumentParser.parse("").isEmpty) + func testEmpty() throws { + XCTAssertTrue(try InstrumentParser.parse("").isEmpty) } } -@Suite("ChartreadArgs") -struct ChartreadArgsTests { +final class ChartreadArgsTests: XCTestCase { - @Test("Baseline argv and port 1 omits -c") - func baseline() throws { + func testBaseline() throws { let config = ChartreadConfig(basename: "target", selectedPort: 1) let args = try ChartreadArgs.build(config: config) - #expect(args == ["-v", "-u", "target"]) + XCTAssertEqual(args, ["-v", "-u", "target"]) } - @Test("Port > 1 emits -c") - func portArgument() throws { + func testPortArgument() throws { let config = ChartreadConfig(basename: "target", selectedPort: 3) let args = try ChartreadArgs.build(config: config) - #expect(args == ["-v", "-u", "-c", "3", "target"]) + XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"]) } - @Test("LEDs emit -Y l") - func leds() throws { + func testLeds() throws { let config = ChartreadConfig( basename: "target", selectedPort: 2, enableLEDs: true ) let args = try ChartreadArgs.build(config: config) - #expect(args.contains("-Y")) - #expect(args.contains("l")) + XCTAssertTrue(args.contains("-Y")) + XCTAssertTrue(args.contains("l")) } - @Test("Auto omits -c") - func autoPort() throws { + func testAutoPort() throws { let config = ChartreadConfig(basename: "target") let args = try ChartreadArgs.build(config: config) - #expect(!args.contains("-c")) + XCTAssertFalse(args.contains("-c")) } } -@Suite("ChartreadClassifier") -struct ChartreadClassifierTests { +final class ChartreadClassifierTests: XCTestCase { - @Test("Calibration prompt") - func calibration() { + func testCalibration() { let r = ChartreadClassifier.classify( line: "Place instrument on calibration tile and hit [Space] to calibrate.", previousState: .idle ) - #expect(r.state == .calibrating) + XCTAssertEqual(r.state, .calibrating) } - @Test("Strip awaiting") - func awaitingStrip() { + func testAwaitingStrip() { let r = ChartreadClassifier.classify( line: "Hit [Space] to read strip A", previousState: .calibrating ) - #expect(r.state == .awaitingStrip) + XCTAssertEqual(r.state, .awaitingStrip) } - @Test("Done prompt") - func done() { + func testDone() { let r = ChartreadClassifier.classify( line: "'d' if/when done", previousState: .awaitingStrip ) - #expect(r.state == .allStripsRead) + XCTAssertEqual(r.state, .allStripsRead) } - @Test("XY place sheet") - func placeSheet() { + func testPlaceSheet() { let r = ChartreadClassifier.classify( line: "Please place sheet 1 of 2 on the table", previousState: .idle ) - #expect(r.state == .tablePlaceSheet) - #expect(r.sheetNumber == 1) - #expect(r.sheetTotal == 2) + XCTAssertEqual(r.state, .tablePlaceSheet) + XCTAssertEqual(r.sheetNumber, 1) + XCTAssertEqual(r.sheetTotal, 2) } - @Test("XY locate patch") - func locatePatch() { + func testLocatePatch() { let r = ChartreadClassifier.classify( line: "locate patch A1 with the sight,", previousState: .tablePlaceSheet ) - #expect(r.state == .tableAlign) - #expect(r.alignmentPatch == "A1") + XCTAssertEqual(r.state, .tableAlign) + XCTAssertEqual(r.alignmentPatch, "A1") } - @Test("Remove sheet notice preserves state") - func removeNotice() { + func testRemoveNotice() { let r = ChartreadClassifier.classify( line: "Please remove last sheet from table", previousState: .tablePlaceSheet ) - #expect(r.state == .tablePlaceSheet) - #expect(r.isRemoveSheetNotice == true) + XCTAssertEqual(r.state, .tablePlaceSheet) + XCTAssertEqual(r.isRemoveSheetNotice, true) } } -@Suite("ChartreadRow") -struct ChartreadRowTests { +final class ChartreadRowTests: XCTestCase { - @Test("Decodes row JSON") - func decode() throws { + func testDecode() throws { let json = """ {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 1, "patches": [ @@ -155,13 +137,12 @@ struct ChartreadRowTests { ]} """ let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) - #expect(row.rowId == "A") - #expect(row.patchCount == 1) - #expect(row.patches[0].measured.lab?.l == 51) + XCTAssertEqual(row.rowId, "A") + XCTAssertEqual(row.patchCount, 1) + XCTAssertEqual(row.patches[0].measured.lab?.l, 51) } - @Test("Decodes a row carrying both XYZ and Lab arrays") - func decodeXYZAndLab() throws { + func testDecodeXYZAndLab() throws { let json = """ {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 1, "patches": [ @@ -171,88 +152,78 @@ struct ChartreadRowTests { """ let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) let measured = row.patches[0].measured - #expect(measured.xyz == CIEXYZ(x: 30.5, y: 32.1, z: 25.9)) - #expect(measured.lab == CIELab(l: 63.4, a: 2.5, b: -8.2)) + XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9)) + XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2)) } - @Test("XYZColor/CIEXYZ encode as an unkeyed three-number array") - func xyzWireEncoding() throws { + func testXyzWireEncoding() throws { for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] { let value = try JSONSerialization.jsonObject( with: JSONEncoder().encode(color)) - #expect(value as? [Double] == [1.5, 2.5, 3.5]) + XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5]) } } - @Test("LabColor/CIELab encode as an unkeyed three-number array") - func labWireEncoding() throws { + func testLabWireEncoding() throws { for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] { let value = try JSONSerialization.jsonObject( with: JSONEncoder().encode(color)) - #expect(value as? [Double] == [50, -1, 2]) + XCTAssertEqual(value as? [Double], [50, -1, 2]) } } - @Test("PatchColor keeps the XYZ and Lab keys over unkeyed arrays") - func patchColorKeys() throws { + func testPatchColorKeys() throws { let color = PatchColor( xyz: CIEXYZ(x: 10, y: 20, z: 30), lab: CIELab(l: 55, a: 1, b: -2)) let object = try JSONSerialization.jsonObject( with: JSONEncoder().encode(color)) as? [String: Any] - #expect(object?["XYZ"] as? [Double] == [10, 20, 30]) - #expect(object?["Lab"] as? [Double] == [55, 1, -2]) - #expect(object?["spectral"] == nil) + XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30]) + XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2]) + XCTAssertNil(object?["spectral"]) } } -@Suite("ColourMath") -struct ColourMathTests { +final class ColourMathTests: XCTestCase { - @Test("White XYZ to Lab") - func whiteLab() { + func testWhiteLab() { let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188) let lab = LabColorMath.xyzToLab(white) - #expect(abs(lab.l - 100) < 0.5) - #expect(abs(lab.a) < 0.5) - #expect(abs(lab.b) < 0.5) + XCTAssertTrue(abs(lab.l - 100) < 0.5) + XCTAssertTrue(abs(lab.a) < 0.5) + XCTAssertTrue(abs(lab.b) < 0.5) } - @Test("Lab to sRGB roundtrip is clamped") - func labToSRGB() { + func testLabToSRGB() { let red = LabColor(l: 55, a: 80, b: 70) let rgb = LabColorMath.labToSRGB(red) - #expect(rgb.r > 0.8) - #expect(rgb.g < 0.2) - #expect(rgb.b < 0.2) + XCTAssertTrue(rgb.r > 0.8) + XCTAssertTrue(rgb.g < 0.2) + XCTAssertTrue(rgb.b < 0.2) } - @Test("Pad white returns DisplayRGB") - func padWhite() { + func testPadWhite() { let white = LabColor(l: 95, a: 0, b: 0) let rgb = LabColorMath.labToSRGB(white) - #expect(rgb.r > 0.9) - #expect(rgb.g > 0.9) - #expect(rgb.b > 0.9) + XCTAssertTrue(rgb.r > 0.9) + XCTAssertTrue(rgb.g > 0.9) + XCTAssertTrue(rgb.b > 0.9) } - @Test("Standard CIEDE2000 vector (Sharma)") - func ciede2000() { + func testCiede2000() { let a = LabColor(l: 50, a: -1.3802, b: -84.2814) let b = LabColor(l: 50, a: 0.0000, b: -82.7485) - #expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001) + XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001) } - @Test("Classification respects thresholds") - func classify() { - #expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good) - #expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning) - #expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad) + func testClassify() { + XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good) + XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning) + XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad) } } -@Suite("MeasurementArtefacts") -struct MeasurementArtefactTests { +final class MeasurementArtefactTests: XCTestCase { private func makeCwd() throws -> URL { let url = FileManager.default.temporaryDirectory @@ -261,8 +232,7 @@ struct MeasurementArtefactTests { return url } - @Test("Discovers passes in order") - func discovery() throws { + func testDiscovery() throws { let cwd = try makeCwd() defer { try? FileManager.default.removeItem(at: cwd) } @@ -271,11 +241,10 @@ struct MeasurementArtefactTests { try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8) let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd) - #expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"]) + XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"]) } - @Test("Snapshot and promote are atomic") - func snapshotPromote() throws { + func testSnapshotPromote() throws { let cwd = try makeCwd() defer { try? FileManager.default.removeItem(at: cwd) } @@ -283,16 +252,15 @@ struct MeasurementArtefactTests { try "canonical".write(to: canonical, atomically: true, encoding: .utf8) let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) - #expect(pass.lastPathComponent == "target_pass1.ti3") - #expect(!FileManager.default.fileExists(atPath: canonical.path)) + XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3") + XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path)) let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd) - #expect(promoted.lastPathComponent == "target.ti3") - #expect(FileManager.default.fileExists(atPath: promoted.path)) + XCTAssertEqual(promoted.lastPathComponent, "target.ti3") + XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path)) } - @Test("Pass collisions handled") - func collision() throws { + func testCollision() throws { let cwd = try makeCwd() defer { try? FileManager.default.removeItem(at: cwd) } @@ -302,28 +270,25 @@ struct MeasurementArtefactTests { try "v2".write(to: canonical, atomically: true, encoding: .utf8) let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) - #expect(pass2.lastPathComponent == "target_pass2.ti3") + XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3") } } -@Suite("AverageArgs") -struct AverageArgsTests { +final class AverageArgsTests: XCTestCase { - @Test("Requires at least two pass files") - func passCount() { + func testPassCount() { let cwd = URL(fileURLWithPath: "/tmp") let config = AverageConfig( workingDirectory: cwd, basename: "target", passFiles: [URL(fileURLWithPath: "target_pass1.ti3")] ) - #expect(throws: AverageArgError.self) { - _ = try AverageArgs.build(config: config) + XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in + XCTAssertTrue(error is AverageArgError) } } - @Test("Output is last and inputs are relative") - func ordering() throws { + func testOrdering() throws { let cwd = URL(fileURLWithPath: "/tmp") let config = AverageConfig( workingDirectory: cwd, @@ -334,8 +299,8 @@ struct AverageArgsTests { ] ) let args = try AverageArgs.build(config: config) - #expect(args.first == "-v") - #expect(args.last == "target.ti3") - #expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"]) + XCTAssertEqual(args.first, "-v") + XCTAssertEqual(args.last, "target.ti3") + XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"]) } } diff --git a/Tests/ICCeryCoreTests/PrintcalArgsTests.swift b/Tests/ICCeryCoreTests/PrintcalArgsTests.swift index 57caf9e..cd56634 100644 --- a/Tests/ICCeryCoreTests/PrintcalArgsTests.swift +++ b/Tests/ICCeryCoreTests/PrintcalArgsTests.swift @@ -1,24 +1,21 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("PrintcalArgs") -struct PrintcalArgsTests { +final class PrintcalArgsTests: XCTestCase { private let tmp = URL(fileURLWithPath: "/tmp/out.cal") - @Test("Default printcal argv") - func defaults() throws { + func testDefaults() throws { let config = PrintcalConfig( ti3Basename: "CAL_demo", outputURL: tmp ) let args = try PrintcalArgs.build(config: config) - #expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) + XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) } - @Test("All options and channel limits") - func allOptions() throws { + func testAllOptions() throws { let config = PrintcalConfig( ti3Basename: "demo", outputURL: tmp, @@ -32,7 +29,7 @@ struct PrintcalArgsTests { ] ) let args = try PrintcalArgs.build(config: config) - #expect(args == [ + XCTAssertEqual(args, [ "-v", "-e", "-I", "-z", "-a", "/tmp/old.cal", @@ -44,39 +41,34 @@ struct PrintcalArgsTests { ]) } - @Test("Whitespace-only previous calibration path emits no -a") - func whitespacePreviousCal() throws { + func testWhitespacePreviousCal() throws { let config = PrintcalConfig( ti3Basename: "demo", outputURL: tmp, previousCalPath: " \n\t " ) let args = try PrintcalArgs.build(config: config) - #expect(!args.contains("-a")) - #expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) + XCTAssertFalse(args.contains("-a")) + XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) } - @Test("Previous calibration path is trimmed before emission") - func previousCalTrimmed() throws { + func testPreviousCalTrimmed() throws { let config = PrintcalConfig( ti3Basename: "demo", outputURL: tmp, previousCalPath: " /tmp/old.cal " ) let args = try PrintcalArgs.build(config: config) - #expect(args[args.firstIndex(of: "-a")! + 1] == "/tmp/old.cal") + XCTAssertEqual(args[args.firstIndex(of: "-a")! + 1], "/tmp/old.cal") } - @Test("Rejects invalid per-channel limit") - func rejectsBadChannelLimit() { + func testRejectsBadChannelLimit() { let config = PrintcalConfig( ti3Basename: "demo", outputURL: tmp, channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)] ) - #expect(throws: (any Error).self) { - _ = try PrintcalArgs.build(config: config) - } + XCTAssertThrowsError(try PrintcalArgs.build(config: config)) } } diff --git a/Tests/ICCeryCoreTests/PrinttargTests.swift b/Tests/ICCeryCoreTests/PrinttargTests.swift index bab9fdc..b147afb 100644 --- a/Tests/ICCeryCoreTests/PrinttargTests.swift +++ b/Tests/ICCeryCoreTests/PrinttargTests.swift @@ -1,9 +1,9 @@ import Testing +import XCTest import Foundation @testable import ICCeryCore -@Suite("PrinttargArgs") -struct PrinttargArgsTests { +final class PrinttargArgsTests: XCTestCase { private func config( instrument: PrintInstrument = .i1, @@ -28,136 +28,121 @@ struct PrinttargArgsTests { ) } - @Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300") - func baseline() throws { + func testBaseline() throws { let args = try PrinttargArgs.build(config: config()) - #expect(args == ["-v", "-u", "-i", "i1", "-p", "A4", + XCTAssertEqual(args, ["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "300", "target"]) } - @Test("Default layout is deterministic -R 1, never bare") - func deterministicDefault() throws { + func testDeterministicDefault() throws { let args = try PrinttargArgs.build(config: config()) - #expect(args.contains("-R")) - #expect(!args.contains("-r")) - #expect(args[args.firstIndex(of: "-R")! + 1] == "1") + XCTAssertTrue(args.contains("-R")) + XCTAssertFalse(args.contains("-r")) + XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1") } - @Test("Custom seed -R N; seed < 1 throws") - func customSeed() throws { + func testCustomSeed() throws { let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42)) - #expect(args[args.firstIndex(of: "-R")! + 1] == "42") - #expect(throws: PrinttargArgError.self) { - try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0)) + XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42") + XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in + XCTAssertTrue(error is PrinttargArgError) } } - @Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)") - func raster() throws { + func testRaster() throws { let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9)) - #expect(args.contains("-r")) - #expect(!args.contains("-R")) + XCTAssertTrue(args.contains("-r")) + XCTAssertFalse(args.contains("-R")) } - @Test("Label: -d emits the resolved string, not a colour space") - func label() throws { + func testLabel() throws { let args = try PrinttargArgs.build( config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04")) let i = args.firstIndex(of: "-d")! - #expect(args[i + 1].hasPrefix("ICCery - t")) + XCTAssertTrue(args[i + 1].hasPrefix("ICCery - t")) } - @Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600") - func bitDepthAndDPI() throws { - #expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600)) + func testBitDepthAndDPI() throws { + XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600)) .contains("-T")) - #expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72)) + XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72)) .contains("-t")) - #expect(throws: PrinttargArgError.self) { - try PrinttargArgs.build(config: config(dpi: 71)) + XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in + XCTAssertTrue(error is PrinttargArgError) } - #expect(throws: PrinttargArgError.self) { - try PrinttargArgs.build(config: config(dpi: 601)) + XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in + XCTAssertTrue(error is PrinttargArgError) } } - @Test("All instruments emit their Argyll code") - func instruments() throws { + func testInstruments() throws { let expected: [(PrintInstrument, String)] = [ (.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"), (.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"), ] for (inst, code) in expected { let args = try PrinttargArgs.build(config: config(instrument: inst)) - #expect(args[args.firstIndex(of: "-i")! + 1] == code) + XCTAssertEqual(args[args.firstIndex(of: "-i")! + 1], code) } } - @Test("All fixed page sizes; custom emits WxH in mm") - func pageSizes() throws { + func testPageSizes() throws { for size in PageSize.allCases where size != .custom { let args = try PrinttargArgs.build(config: config(pageSize: size)) - #expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue) + XCTAssertEqual(args[args.firstIndex(of: "-p")! + 1], size.rawValue) } let custom = try PrinttargArgs.build(config: config( pageSize: .custom, customW: 150, customH: 220)) - #expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220") + XCTAssertEqual(custom[custom.firstIndex(of: "-p")! + 1], "150x220") } - @Test("Custom page below 50 mm throws") - func customPageTooSmall() { - #expect(throws: PrinttargArgError.self) { - try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9)) + func testCustomPageTooSmall() { + XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in + XCTAssertTrue(error is PrinttargArgError) } - #expect(throws: PrinttargArgError.self) { - try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10)) + XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in + XCTAssertTrue(error is PrinttargArgError) } } - @Test("Calibration: -K applies, -I embeds") - func calibrationFlags() throws { + func testCalibrationFlags() throws { let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal")) - #expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal") + XCTAssertEqual(k[k.firstIndex(of: "-K")! + 1], "/tmp/a.cal") let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true)) - #expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal") - #expect(!i.contains("-K")) + XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal") + XCTAssertFalse(i.contains("-K")) } - @Test("CAL_ basename never gets -K or -I") - func calProtection() throws { + func testCalProtection() throws { let args = try PrinttargArgs.build( config: config(calFile: "/tmp/a.cal", basename: "CAL_test")) - #expect(!args.contains("-K")) - #expect(!args.contains("-I")) + XCTAssertFalse(args.contains("-K")) + XCTAssertFalse(args.contains("-I")) } - @Test("Whitespace-only label emits no -d; whitespace-only calibration emits no -K/-I") - func whitespaceOptions() throws { + func testWhitespaceOptions() throws { let args = try PrinttargArgs.build( config: config(label: " \n ", calFile: " \t ")) - #expect(!args.contains("-d")) - #expect(!args.contains("-K")) - #expect(!args.contains("-I")) + XCTAssertFalse(args.contains("-d")) + XCTAssertFalse(args.contains("-K")) + XCTAssertFalse(args.contains("-I")) } - @Test("Label and calibration values are trimmed before emission") - func trimmedOptions() throws { + func testTrimmedOptions() throws { let args = try PrinttargArgs.build( config: config(label: " My Label ", calFile: " /tmp/a.cal ")) - #expect(args[args.firstIndex(of: "-d")! + 1] == "My Label") - #expect(args[args.firstIndex(of: "-K")! + 1] == "/tmp/a.cal") + XCTAssertEqual(args[args.firstIndex(of: "-d")! + 1], "My Label") + XCTAssertEqual(args[args.firstIndex(of: "-K")! + 1], "/tmp/a.cal") } - @Test("Unsafe basename throws") - func unsafeBasename() { - #expect(throws: PathSecurity.Error.self) { - try PrinttargArgs.build(config: config(basename: "../x")) + func testUnsafeBasename() { + XCTAssertThrowsError(try PrinttargArgs.build(config: config(basename: "../x"))) { error in + XCTAssertTrue(error is PathSecurity.Error) } } } -@Suite("PrinttargLabel") -struct PrinttargLabelTests { +final class PrinttargLabelTests: XCTestCase { private var fixedDate: Date { var comps = DateComponents() @@ -166,37 +151,33 @@ struct PrinttargLabelTests { return Calendar(identifier: .gregorian).date(from: comps)! } - @Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM") - func automatic() { + func testAutomatic() { let label = PrinttargLabel.automatic( basename: "tgt", metadata: TargetLabelMetadata( printer: "Epson", inkSet: "CMYK", driverPaper: "Photo", actualPaper: "Matte"), date: fixedDate, timeZone: .current) - #expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - ")) - #expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026")) + XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - ")) + XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026")) } - @Test("Missing metadata becomes Unspecified") - func unspecified() { + func testUnspecified() { let label = PrinttargLabel.automatic( basename: "tgt", metadata: TargetLabelMetadata(), date: fixedDate, timeZone: .current) - #expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - ")) + XCTAssertTrue(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - ")) } - @Test("Manual label wins over automatic") - func manualWins() { + func testManualWins() { let resolved = PrinttargLabel.resolved( customLabel: " My Label ", basename: "tgt", metadata: TargetLabelMetadata(), date: fixedDate) - #expect(resolved == "My Label") + XCTAssertEqual(resolved, "My Label") } } -@Suite("PrinttargManifest") -struct PrinttargManifestTests { +final class PrinttargManifestTests: XCTestCase { private let prettySingle = """ Some log line @@ -225,59 +206,52 @@ struct PrinttargManifestTests { } """ - @Test("Decodes a single-page pretty manifest amid log noise") - func singlePage() throws { + func testSinglePage() throws { let m = try PrinttargManifestExtractor.manifest(from: prettySingle) - #expect(m.event == "manifest") - #expect(m.pages.count == 1) - #expect(m.pages[0].filename == "target.tif") - #expect(m.pages[0].patches == 800) + XCTAssertEqual(m.event, "manifest") + XCTAssertEqual(m.pages.count, 1) + XCTAssertEqual(m.pages[0].filename, "target.tif") + XCTAssertEqual(m.pages[0].patches, 800) } - @Test("Multi-page manifest preserves order") - func multiPage() throws { + func testMultiPage() throws { let m = try PrinttargManifestExtractor.manifest(from: prettyMulti) - #expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"]) + XCTAssertEqual(m.pages.map(\.filename), ["p1.tif", "p2.tif"]) } - @Test("No JSON document → noJSONDocument") - func noJSON() { - #expect(throws: ManifestError.self) { - try PrinttargManifestExtractor.manifest(from: "plain text\nno json") + func testNoJSON() { + XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in + XCTAssertTrue(error is ManifestError) } } - @Test("Wrong event → wrongEvent") - func wrongEvent() { + func testWrongEvent() { let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n" - #expect(throws: ManifestError.self) { - try PrinttargManifestExtractor.manifest(from: stdout) + XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in + XCTAssertTrue(error is ManifestError) } } - @Test("ROW_COLORS_JSON line is never treated as the manifest") - func rowColorsNotManifest() { + func testRowColorsNotManifest() { let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}" // Extraction only starts at a '{' that begins a trimmed line, // so the ROW_COLORS_JSON line is skipped entirely. let m = try? PrinttargManifestExtractor.manifest(from: stdout) - #expect(m != nil) - #expect(m?.event == "manifest") + XCTAssertNotNil(m) + XCTAssertEqual(m?.event, "manifest") } - @Test("Braces inside a quoted filename do not corrupt the scan") - func bracesInFilename() throws { + func testBracesInFilename() throws { let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n" let m = try PrinttargManifestExtractor.manifest(from: stdout) - #expect(m.pages[0].filename == "a}b.tif") + XCTAssertEqual(m.pages[0].filename, "a}b.tif") } - @Test("Unsafe / non-TIFF filenames rejected") - func unsafeFilenames() { + func testUnsafeFilenames() { for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] { let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}" - #expect(throws: ManifestError.self) { - try PrinttargManifestExtractor.manifest(from: stdout) + XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in + XCTAssertTrue(error is ManifestError) } } } diff --git a/Tests/ICCeryCoreTests/ProcessManagerTests.swift b/Tests/ICCeryCoreTests/ProcessManagerTests.swift index 716bf92..d3a3f3a 100644 --- a/Tests/ICCeryCoreTests/ProcessManagerTests.swift +++ b/Tests/ICCeryCoreTests/ProcessManagerTests.swift @@ -1,4 +1,5 @@ import Testing +import XCTest import Foundation @testable import ICCeryCore @@ -408,65 +409,62 @@ struct ProcessManagerTests { } } -@Suite("ProcessLineDecoder") -struct ProcessLineDecoderTests { - @Test func splitsAcrossChunkBoundaries() { +final class ProcessLineDecoderTests: XCTestCase { + func testSplitsAcrossChunkBoundaries() { var d = ProcessLineDecoder() - #expect(d.feed(Data("he".utf8)) == []) - #expect(d.feed(Data("llo\nwor".utf8)) == ["hello"]) - #expect(d.feed(Data("ld\n".utf8)) == ["world"]) - #expect(d.finish() == nil) + XCTAssertEqual(d.feed(Data("he".utf8)), []) + XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"]) + XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"]) + XCTAssertNil(d.finish()) } - @Test func crlfIsStripped() { + func testCrlfIsStripped() { var d = ProcessLineDecoder() - #expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"]) + XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"]) } - @Test func finishReturnsRemainder() { + func testFinishReturnsRemainder() { var d = ProcessLineDecoder() _ = d.feed(Data("x".utf8)) - #expect(d.finish() == "x") - #expect(d.finish() == nil) + XCTAssertEqual(d.finish(), "x") + XCTAssertNil(d.finish()) } } -@Suite("JSONAccumulator") -struct JSONAccumulatorTests { - @Test func multilinePrettyJSON() { +final class JSONAccumulatorTests: XCTestCase { + func testMultilinePrettyJSON() { var acc = JSONAccumulator() - #expect(acc.feed(line: "{") == nil) - #expect(acc.feed(line: " \"k\": 1") == nil) + XCTAssertNil(acc.feed(line: "{")) + XCTAssertNil(acc.feed(line: " \"k\": 1")) let done = acc.feed(line: "}") - #expect(done != nil) + XCTAssertNotNil(done) let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int] - #expect(obj?["k"] == 1) + XCTAssertEqual(obj?["k"], 1) } - @Test func nonJSONLinesIgnored() { + func testNonJSONLinesIgnored() { var acc = JSONAccumulator() - #expect(acc.feed(line: "Reading instrument...") == nil) - #expect(acc.feed(line: "still text") == nil) - #expect(acc.completeData == nil) + XCTAssertNil(acc.feed(line: "Reading instrument...")) + XCTAssertNil(acc.feed(line: "still text")) + XCTAssertNil(acc.completeData) } - @Test func decodeTyped() { + func testDecodeTyped() { struct Doc: Decodable { let n: Int } var acc = JSONAccumulator() // Split so the doc completes on the second feed. - #expect(acc.feed(line: "{\"n\":") == nil) + XCTAssertNil(acc.feed(line: "{\"n\":")) let data = acc.feed(line: "7}") - #expect(data != nil) + XCTAssertNotNil(data) let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) } - #expect(doc?.n == 7) - #expect(acc.isEmpty) + XCTAssertEqual(doc?.n, 7) + XCTAssertTrue(acc.isEmpty) } } -@Suite("LogSanitizer") -struct LogSanitizerTests { - @Test func homeIsRewritten() { +final class LogSanitizerTests: XCTestCase { + func testHomeIsRewritten() { let path = "\(NSHomeDirectory())/Documents/foo.ti1" - #expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1") + XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1") } } diff --git a/Tests/ICCeryCoreTests/ProfcheckArgsTests.swift b/Tests/ICCeryCoreTests/ProfcheckArgsTests.swift index 4ac887d..09b461f 100644 --- a/Tests/ICCeryCoreTests/ProfcheckArgsTests.swift +++ b/Tests/ICCeryCoreTests/ProfcheckArgsTests.swift @@ -1,17 +1,15 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("ProfcheckArgs") -struct ProfcheckArgsTests { +final class ProfcheckArgsTests: XCTestCase { - @Test("Hard-coded argv") - func argv() throws { + func testArgv() throws { let config = ProfcheckConfig( ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"), iccURL: URL(fileURLWithPath: "/tmp/target.icc") ) let args = try ProfcheckArgs.build(config: config) - #expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"]) + XCTAssertEqual(args, ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"]) } } diff --git a/Tests/ICCeryCoreTests/ProfcheckParserTests.swift b/Tests/ICCeryCoreTests/ProfcheckParserTests.swift index 30ea74f..50b165d 100644 --- a/Tests/ICCeryCoreTests/ProfcheckParserTests.swift +++ b/Tests/ICCeryCoreTests/ProfcheckParserTests.swift @@ -1,43 +1,39 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("ProfcheckParser") -struct ProfcheckParserTests { +final class ProfcheckParserTests: XCTestCase { - @Test("Prefers JSON report with de2000 keys") - func jsonReport() { + func testJsonReport() { let output = """ No of test patches = 52 {"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02} Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99 """ let report = ProfcheckParser.parse(output) - #expect(report.isValid == true) - #expect(report.patchCount == 52) - #expect(report.avgDE == 0.85) - #expect(report.maxDE == 2.41) - #expect(report.rmsDE == 1.02) - #expect(report.status == .excellent) + XCTAssertEqual(report.isValid, true) + XCTAssertEqual(report.patchCount, 52) + XCTAssertEqual(report.avgDE, 0.85) + XCTAssertEqual(report.maxDE, 2.41) + XCTAssertEqual(report.rmsDE, 1.02) + XCTAssertEqual(report.status, .excellent) } - @Test("Falls back to legacy text") - func legacyText() { + func testLegacyText() { let output = """ No of test patches = 120 Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95 """ let report = ProfcheckParser.parse(output) - #expect(report.isValid == true) - #expect(report.patchCount == 120) - #expect(report.avgDE == 1.80) - #expect(report.maxDE == 3.50) - #expect(report.rmsDE == 0.95) - #expect(report.status == .good) + XCTAssertEqual(report.isValid, true) + XCTAssertEqual(report.patchCount, 120) + XCTAssertEqual(report.avgDE, 1.80) + XCTAssertEqual(report.maxDE, 3.50) + XCTAssertEqual(report.rmsDE, 0.95) + XCTAssertEqual(report.status, .good) } - @Test("Broad regex fallback") - func regexFallback() { + func testRegexFallback() { let output = """ No of test patches = 10 avg = 4.25 @@ -45,27 +41,25 @@ struct ProfcheckParserTests { rms = 2.30 """ let report = ProfcheckParser.parse(output) - #expect(report.isValid == true) - #expect(report.avgDE == 4.25) - #expect(report.maxDE == 6.10) - #expect(report.rmsDE == 2.30) - #expect(report.status == .poor) + XCTAssertEqual(report.isValid, true) + XCTAssertEqual(report.avgDE, 4.25) + XCTAssertEqual(report.maxDE, 6.10) + XCTAssertEqual(report.rmsDE, 2.30) + XCTAssertEqual(report.status, .poor) } - @Test("Unparseable output warns, not zeros") - func unparseable() { + func testUnparseable() { let output = "some random text without metrics" let report = ProfcheckParser.parse(output) - #expect(report.isValid == false) - #expect(report.warning != nil) - #expect(report.avgDE == nil) + XCTAssertEqual(report.isValid, false) + XCTAssertNotNil(report.warning) + XCTAssertNil(report.avgDE) } - @Test("Status bands") - func statusBands() { - #expect(VerificationStatus.from(avgDE: 0.5) == .excellent) - #expect(VerificationStatus.from(avgDE: 1.5) == .good) - #expect(VerificationStatus.from(avgDE: 2.5) == .acceptable) - #expect(VerificationStatus.from(avgDE: 4.0) == .poor) + func testStatusBands() { + XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent) + XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good) + XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable) + XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor) } } diff --git a/Tests/ICCeryCoreTests/TargenTests.swift b/Tests/ICCeryCoreTests/TargenTests.swift index 821efce..c11a0a1 100644 --- a/Tests/ICCeryCoreTests/TargenTests.swift +++ b/Tests/ICCeryCoreTests/TargenTests.swift @@ -1,12 +1,11 @@ import Testing +import XCTest import Foundation @testable import ICCeryCore -@Suite("TargenArgs") -struct TargenArgsTests { +final class TargenArgsTests: XCTestCase { - @Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4") - func rgbBaseline() throws { + func testRgbBaseline() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -15,12 +14,11 @@ struct TargenArgsTests { basename: "test_rgb" ) let args = try TargenArgs.build(config: config) - #expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"]) - #expect(!args.contains("-u")) + XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"]) + XCTAssertFalse(args.contains("-u")) } - @Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0") - func cmykBaseline() throws { + func testCmykBaseline() throws { let config = TargenConfig( colourSpace: .cmyk, patchCount: 1500, @@ -29,11 +27,10 @@ struct TargenArgsTests { basename: "test_cmyk" ) let args = try TargenArgs.build(config: config) - #expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"]) + XCTAssertEqual(args, ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"]) } - @Test("Custom patch count honours -f (#44)") - func customPatchCount() throws { + func testCustomPatchCount() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 2500, @@ -42,12 +39,11 @@ struct TargenArgsTests { basename: "custom_patches" ) let args = try TargenArgs.build(config: config) - #expect(args.contains("-f")) - #expect(args[args.firstIndex(of: "-f")! + 1] == "2500") + XCTAssertTrue(args.contains("-f")) + XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500") } - @Test("All advanced flags in stable order") - func allAdvancedFlags() throws { + func testAllAdvancedFlags() throws { let config = TargenConfig( colourSpace: .cmyk, patchCount: 1200, @@ -85,11 +81,10 @@ struct TargenArgsTests { "-p", "2.00", "advanced_cmyk" ] - #expect(args == expected) + XCTAssertEqual(args, expected) } - @Test("RGB ignores total ink limit") - func rgbIgnoresInkLimit() throws { + func testRgbIgnoresInkLimit() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -99,11 +94,10 @@ struct TargenArgsTests { basename: "rgb_no_ink" ) let args = try TargenArgs.build(config: config) - #expect(!args.contains("-l")) + XCTAssertFalse(args.contains("-l")) } - @Test("Neutral concentration omitted when approximately 0.50") - func neutralConcentrationOmittedWhenDefault() throws { + func testNeutralConcentrationOmittedWhenDefault() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -113,11 +107,10 @@ struct TargenArgsTests { basename: "n_default" ) let args = try TargenArgs.build(config: config) - #expect(!args.contains("-N")) + XCTAssertFalse(args.contains("-N")) } - @Test("Adaptation emitted even at 0.10 (no default-skip)") - func adaptationEmittedAtPointOne() throws { + func testAdaptationEmittedAtPointOne() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -127,12 +120,11 @@ struct TargenArgsTests { basename: "a_flag" ) let args = try TargenArgs.build(config: config) - #expect(args.contains("-A")) - #expect(args[args.firstIndex(of: "-A")! + 1] == "0.10") + XCTAssertTrue(args.contains("-A")) + XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10") } - @Test("OFPS full spread algorithm emits no flag") - func ofpsEmitsNoFlag() throws { + func testOfpsEmitsNoFlag() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -142,12 +134,11 @@ struct TargenArgsTests { basename: "ofps_test" ) let args = try TargenArgs.build(config: config) - #expect(!args.contains("ofps")) - #expect(!args.contains("-t")) + XCTAssertFalse(args.contains("ofps")) + XCTAssertFalse(args.contains("-t")) } - @Test("Dark emphasis and device power omitted when 1.0") - func darkEmphasisAndPowerOmittedWhenOne() throws { + func testDarkEmphasisAndPowerOmittedWhenOne() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -158,12 +149,11 @@ struct TargenArgsTests { basename: "defaults_omitted" ) let args = try TargenArgs.build(config: config) - #expect(!args.contains("-V")) - #expect(!args.contains("-p")) + XCTAssertFalse(args.contains("-V")) + XCTAssertFalse(args.contains("-p")) } - @Test("Whitespace-only preconditioning profile emits no -c") - func whitespacePreconditioner() throws { + func testWhitespacePreconditioner() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -173,11 +163,10 @@ struct TargenArgsTests { basename: "ws_pre" ) let args = try TargenArgs.build(config: config) - #expect(!args.contains("-c")) + XCTAssertFalse(args.contains("-c")) } - @Test("Preconditioning profile is trimmed before emission") - func preconditionerTrimmed() throws { + func testPreconditionerTrimmed() throws { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -187,11 +176,10 @@ struct TargenArgsTests { basename: "trim_pre" ) let args = try TargenArgs.build(config: config) - #expect(args[args.firstIndex(of: "-c")! + 1] == "/path/to/profile.icc") + XCTAssertEqual(args[args.firstIndex(of: "-c")! + 1], "/path/to/profile.icc") } - @Test("Invalid basename throws") - func invalidBasenameThrows() { + func testInvalidBasenameThrows() { let config = TargenConfig( colourSpace: .rgb, patchCount: 800, @@ -199,13 +187,12 @@ struct TargenArgsTests { blackPatches: 4, basename: "../bad_name" ) - #expect(throws: PathSecurity.Error.self) { - try TargenArgs.build(config: config) + XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in + XCTAssertTrue(error is PathSecurity.Error) } } - @Test("Invalid patch count throws") - func invalidPatchCountThrows() { + func testInvalidPatchCountThrows() { let config = TargenConfig( colourSpace: .rgb, patchCount: 0, @@ -213,13 +200,12 @@ struct TargenArgsTests { blackPatches: 4, basename: "bad_count" ) - #expect(throws: TargenArgError.self) { - try TargenArgs.build(config: config) + XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in + XCTAssertTrue(error is TargenArgError) } } - @Test("Invalid ink limit throws for CMYK") - func invalidInkLimitThrows() { + func testInvalidInkLimitThrows() { let config = TargenConfig( colourSpace: .cmyk, patchCount: 800, @@ -228,8 +214,8 @@ struct TargenArgsTests { totalInkLimit: 450, basename: "bad_ink" ) - #expect(throws: TargenArgError.self) { - try TargenArgs.build(config: config) + XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in + XCTAssertTrue(error is TargenArgError) } } } diff --git a/Tests/ICCeryCoreTests/TestAppEnvironment.swift b/Tests/ICCeryCoreTests/TestAppEnvironment.swift index ac27fe4..1a7f9d0 100644 --- a/Tests/ICCeryCoreTests/TestAppEnvironment.swift +++ b/Tests/ICCeryCoreTests/TestAppEnvironment.swift @@ -1,4 +1,3 @@ -import Testing import Foundation @testable import ICCeryCore @testable import ICCery diff --git a/Tests/ICCeryCoreTests/XCTestCase+AsyncThrows.swift b/Tests/ICCeryCoreTests/XCTestCase+AsyncThrows.swift new file mode 100644 index 0000000..c26ea15 --- /dev/null +++ b/Tests/ICCeryCoreTests/XCTestCase+AsyncThrows.swift @@ -0,0 +1,21 @@ +import XCTest + +extension XCTestCase { + func assertAsyncThrows( + expectedType: E.Type, + _ expression: () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + errorHandler: ((E) -> Void)? = nil + ) async { + do { + _ = try await expression() + XCTFail("Expected \(expectedType) to be thrown but expression succeeded. \(message())", file: file, line: line) + } catch let error as E { + errorHandler?(error) + } catch { + XCTFail("Expected \(expectedType) but caught \(type(of: error)): \(error). \(message())", file: file, line: line) + } + } +} -- 2.39.5 From a30a8fc551d69fc6402f40a84bf3f89572801427 Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 11 Sep 2026 21:29:16 +0100 Subject: [PATCH 03/25] test(m9): complete migration of all remaining test suites to XCTest Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ArgyllRunnerCalibrationTests.swift | 36 +- .../ArgyllRunnerColprofTests.swift | 23 +- .../ArgyllRunnerStreamingLoopTests.swift | 67 ++- .../CalibrationStoreTests.swift | 28 +- .../ICCeryCoreTests/JSONFileStoreTests.swift | 35 +- Tests/ICCeryCoreTests/PresetTests.swift | 465 +++++++++--------- .../PresetViewModelMappingTests.swift | 70 ++- Tests/ICCeryCoreTests/PrintPanelTests.swift | 30 +- Tests/ICCeryCoreTests/PrinttargTests.swift | 41 +- .../ICCeryCoreTests/ProcessManagerTests.swift | 189 +++---- .../ProcessRunSupportTests.swift | 27 +- .../ProfileInstallerTests.swift | 65 ++- Tests/ICCeryCoreTests/SettingsTests.swift | 111 ++--- Tests/ICCeryCoreTests/TargenTests.swift | 31 +- .../TargetWorkflowViewModelTests.swift | 23 +- .../VerificationHistoryStoreTests.swift | 62 +-- .../WizardCalibrationSessionTests.swift | 38 +- Tests/ICCeryCoreTests/WizardGatingTests.swift | 96 ++-- 18 files changed, 685 insertions(+), 752 deletions(-) diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift index 204fdc0..cf89008 100644 --- a/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift +++ b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift @@ -1,9 +1,8 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("ArgyllRunner Calibration") -struct ArgyllRunnerCalibrationTests { +final class ArgyllRunnerCalibrationTests: XCTestCase { private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner { let binDir = URL(fileURLWithPath: #filePath) @@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests { return root } - @Test("Calibration targen produces CAL_*.ti1") - func calibrationTargenProducesTi1() async throws { + func testCalibrationTargenProducesTi1() async throws { let testRoot = try makeTestDir() let runner = makeRunner() let config = CalibrationTargenConfig( @@ -36,13 +34,12 @@ struct ArgyllRunnerCalibrationTests { let url = try await runner.runCalibrationTargen(config: config) - #expect(url.lastPathComponent == "CAL_demo.ti1") - #expect(FileManager.default.fileExists(atPath: url.path)) + XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) try? FileManager.default.removeItem(at: testRoot) } - @Test("Calibration targen from foo runs as process id targen_CAL_foo") - func calibrationTargenProcessId() async throws { + func testCalibrationTargenProcessId() async throws { let testRoot = try makeTestDir() let pm = ProcessManager() let runner = makeRunner(processManager: pm) @@ -65,13 +62,13 @@ struct ArgyllRunnerCalibrationTests { let url = try await runner.runCalibrationTargen(config: config) - #expect(url.lastPathComponent == "CAL_foo.ti1") - #expect(await sawExit.value) + XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1") + let sawExitEvent = await sawExit.value + XCTAssertTrue(sawExitEvent) try? FileManager.default.removeItem(at: testRoot) } - @Test("printcal captured run creates .cal") - func printcalProducesCal() async throws { + func testPrintcalProducesCal() async throws { let testRoot = try makeTestDir() let runner = makeRunner() let output = testRoot.appendingPathComponent("CAL_demo.cal") @@ -83,13 +80,12 @@ struct ArgyllRunnerCalibrationTests { let url = try await runner.runPrintcal(config: config) - #expect(url.lastPathComponent == "CAL_demo.cal") - #expect(FileManager.default.fileExists(atPath: url.path)) + XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) try? FileManager.default.removeItem(at: testRoot) } - @Test("printcal failure throws toolFailed") - func printcalFailureThrows() async throws { + func testPrintcalFailureThrows() async throws { let testRoot = try makeTestDir() defer { try? FileManager.default.removeItem(at: testRoot) } @@ -117,9 +113,11 @@ struct ArgyllRunnerCalibrationTests { outputURL: output ) - await #expect(throws: ArgyllRunnerError.toolFailed( - tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) { + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { _ = try await runner.runPrintcal(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .toolFailed( + tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) } } } diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift index 6cf0d4e..1769fc4 100644 --- a/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift +++ b/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift @@ -1,5 +1,5 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore final class LogHolder: @unchecked Sendable { @@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable { } } -@Suite("ArgyllRunner colprof") -struct ArgyllRunnerColprofTests { +final class ArgyllRunnerColprofTests: XCTestCase { - @Test("Mock colprof produces .icc") - func colprofProducesIcc() async throws { + func testColprofProducesIcc() async throws { let binDir = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() @@ -43,15 +41,14 @@ struct ArgyllRunnerColprofTests { holder.append(batch) } - #expect(url.lastPathComponent == "testrun.icc") - #expect(FileManager.default.fileExists(atPath: url.path)) - #expect(holder.lines.contains { $0.contains("Gamut mapping") }) + XCTAssertEqual(url.lastPathComponent, "testrun.icc") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") }) try? FileManager.default.removeItem(at: testRoot) } - @Test("Failing colprof throws toolFailed with code and logs") - func colprofFailureThrowsToolFailed() async throws { + func testColprofFailureThrowsToolFailed() async throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("colprof-fail-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -72,9 +69,11 @@ struct ArgyllRunnerColprofTests { ) let config = ColprofConfig(basename: "failrun", workingDirectory: dir) - await #expect(throws: ArgyllRunnerError.toolFailed( - tool: "colprof", code: 4, logs: ["colprof broke"])) { + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { try await runner.runColprof(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .toolFailed( + tool: "colprof", code: 4, logs: ["colprof broke"])) } } } diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift index c1d114b..083401a 100644 --- a/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift +++ b/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift @@ -1,5 +1,5 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore /// Focused contracts for the shared `runStreamingTool` loop (#79). @@ -7,8 +7,7 @@ import Testing /// Every test uses a per-test temporary directory, unique basenames, /// and a fresh `ProcessManager` — no shared UI fixture scripts and no /// process-environment mutation. -@Suite("ArgyllRunner streaming loop contracts") -struct ArgyllRunnerStreamingLoopTests { +final class ArgyllRunnerStreamingLoopTests: XCTestCase { private func makeTempDir() throws -> URL { let dir = FileManager.default.temporaryDirectory @@ -30,8 +29,7 @@ struct ArgyllRunnerStreamingLoopTests { binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)) } - @Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines") - func nonZeroExitThrowsToolFailed() async throws { + func testNonZeroExitThrowsToolFailed() async throws { let dir = try makeTempDir() defer { try? FileManager.default.removeItem(at: dir) } try writeMock("targen", """ @@ -47,21 +45,20 @@ struct ArgyllRunnerStreamingLoopTests { do { _ = try await runner.runTargen(config: config) - Issue.record("Expected toolFailed") + XCTFail("Expected toolFailed") } catch let error as ArgyllRunnerError { guard case .toolFailed(let tool, let code, let logs) = error else { - Issue.record("Expected toolFailed, got \(error)") + XCTFail("Expected toolFailed, got \(error)") return } - #expect(tool == "targen") - #expect(code == 3) - #expect(logs.contains("Generating patches...")) - #expect(logs.contains("targen: too few patches")) + XCTAssertEqual(tool, "targen") + XCTAssertEqual(code, 3) + XCTAssertTrue(logs.contains("Generating patches...")) + XCTAssertTrue(logs.contains("targen: too few patches")) } } - @Test("Exit 0 without expected artefact throws missingArtefact with the artefact path") - func zeroExitMissingArtefact() async throws { + func testZeroExitMissingArtefact() async throws { let dir = try makeTempDir() defer { try? FileManager.default.removeItem(at: dir) } try writeMock("targen", """ @@ -75,13 +72,14 @@ struct ArgyllRunnerStreamingLoopTests { colourSpace: .rgb, patchCount: 800, whitePatches: 4, blackPatches: 4, basename: "gone", workingDirectory: dir) - await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) { + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { try await runner.runTargen(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .missingArtefact(expectedPath)) } } - @Test("Immediate exit after one stdout line still delivers the line and succeeds") - func immediateExitDeliversLine() async throws { + func testImmediateExitDeliversLine() async throws { let dir = try makeTempDir() defer { try? FileManager.default.removeItem(at: dir) } try writeMock("targen", """ @@ -101,13 +99,12 @@ struct ArgyllRunnerStreamingLoopTests { let url = try await runner.runTargen(config: config) { batch in holder.append(batch) } - #expect(url.lastPathComponent == "quick.ti1") - #expect(FileManager.default.fileExists(atPath: url.path)) - #expect(holder.lines.contains("only line")) + XCTAssertEqual(url.lastPathComponent, "quick.ti1") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertTrue(holder.lines.contains("only line")) } - @Test("colprof unterminated progress fragment reaches onLogBatch before exit") - func colprofPartialLineFlush() async throws { + func testColprofPartialLineFlush() async throws { let dir = try makeTempDir() defer { try? FileManager.default.removeItem(at: dir) } // The fragment is printed without a newline, then the mock sleeps @@ -129,13 +126,13 @@ struct ArgyllRunnerStreamingLoopTests { let url = try await runner.runColprof(config: config) { batch in holder.append(batch) } - #expect(url.lastPathComponent == "frag.icc") - #expect(FileManager.default.fileExists(atPath: url.path)) - #expect(holder.lines.contains("Doing gamut mapping")) + XCTAssertEqual(url.lastPathComponent, "frag.icc") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertTrue(holder.lines.contains("Doing gamut mapping")) } - @Test("toolFailed maps each tool to its user-facing description", - arguments: [ + func testToolDescriptions() { + let cases: [(tool: String, expected: String)] = [ (tool: "chartread", expected: "Chartread failed: boom"), (tool: "average", expected: "Averaging failed: boom"), (tool: "colprof", expected: "Profile creation failed: boom"), @@ -143,19 +140,19 @@ struct ArgyllRunnerStreamingLoopTests { (tool: "applycal", expected: "Apply calibration failed: boom"), (tool: "iccgamut", expected: "Gamut extraction failed: boom"), (tool: "profcheck", expected: "Profile verification failed: boom"), - ]) - func toolDescriptions(tool: String, expected: String) { - let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"]) - #expect(error.errorDescription == expected) + ] + for (tool, expected) in cases { + let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"]) + XCTAssertEqual(error.errorDescription, expected) + } } - @Test("toolFailed falls back to a generic description for unmapped tools and empty logs") - func genericFallbacks() { + func testGenericFallbacks() { let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"]) - #expect(unknown.errorDescription == "Process exited with code 7") + XCTAssertEqual(unknown.errorDescription, "Process exited with code 7") let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: []) - #expect(emptyLogs.errorDescription - == "Profile creation failed: exited with code 2") + XCTAssertEqual(emptyLogs.errorDescription, + "Profile creation failed: exited with code 2") } } diff --git a/Tests/ICCeryCoreTests/CalibrationStoreTests.swift b/Tests/ICCeryCoreTests/CalibrationStoreTests.swift index 8b7ab56..509d71f 100644 --- a/Tests/ICCeryCoreTests/CalibrationStoreTests.swift +++ b/Tests/ICCeryCoreTests/CalibrationStoreTests.swift @@ -1,9 +1,8 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("CalibrationStore") -struct CalibrationStoreTests { +final class CalibrationStoreTests: XCTestCase { private static let sampleCal = """ CTI3 @@ -23,8 +22,7 @@ struct CalibrationStoreTests { END_DATA """ - @Test("Loads metadata and curves from .cal") - func parseCal() async throws { + func testParseCal() async throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("test_\(UUID().uuidString).cal") try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) @@ -33,17 +31,16 @@ struct CalibrationStoreTests { try await store.load(url: url) let data = await store.data - #expect(data?.colorRep == "RGB") - #expect(data?.descriptor == "Test printer") - #expect(data?.maxTac == 300) - #expect(data?.curves.count == 3) + XCTAssertEqual(data?.colorRep, "RGB") + XCTAssertEqual(data?.descriptor, "Test printer") + XCTAssertEqual(data?.maxTac, 300) + XCTAssertEqual(data?.curves.count, 3) let r = data?.curves.first { $0.channel == "R" } - #expect(r?.output == [0, 64, 255]) + XCTAssertEqual(r?.output, [0, 64, 255]) } - @Test("Staleness is true for a very old calibration") - func staleCalibration() async throws { + func testStaleCalibration() async throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("stale_\(UUID().uuidString).cal") try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) @@ -51,11 +48,10 @@ struct CalibrationStoreTests { let store = CalibrationStore(staleDays: 0) try await store.load(url: url) let stale = await store.isStale(comparedTo: "Other") - #expect(stale == true) + XCTAssertEqual(stale, true) } - @Test("Printer mismatch is flagged as stale") - func printerMismatch() async throws { + func testPrinterMismatch() async throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("mismatch_\(UUID().uuidString).cal") try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) @@ -64,6 +60,6 @@ struct CalibrationStoreTests { try await store.load(url: url) await store.setPrinterName("Printer A") let stale = await store.isStale(comparedTo: "Printer B") - #expect(stale == true) + XCTAssertEqual(stale, true) } } diff --git a/Tests/ICCeryCoreTests/JSONFileStoreTests.swift b/Tests/ICCeryCoreTests/JSONFileStoreTests.swift index c64bdaf..4d244d5 100644 --- a/Tests/ICCeryCoreTests/JSONFileStoreTests.swift +++ b/Tests/ICCeryCoreTests/JSONFileStoreTests.swift @@ -1,26 +1,23 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("JSONFileStore") -struct JSONFileStoreTests { +final class JSONFileStoreTests: XCTestCase { private func tempURL() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent("json-store-\(UUID().uuidString).json") } - @Test("Missing file returns default") - func missingFileDefaults() throws { + func testMissingFileDefaults() throws { let store = JSONFileStore( fileURL: tempURL(), corrupt: .throwCorrupt, defaultValue: { .default } ) - #expect(try store.load() == .default) + XCTAssertEqual(try store.load(), .default) } - @Test("Corrupt file with replaceWithDefault returns default and leaves bytes") - func corruptDefaults() throws { + func testCorruptDefaults() throws { let url = tempURL() try "{ not json".write(to: url, atomically: true, encoding: .utf8) let store = JSONFileStore( @@ -28,13 +25,12 @@ struct JSONFileStoreTests { corrupt: .replaceWithDefault, defaultValue: { .default } ) - #expect(try store.load() == .default) + XCTAssertEqual(try store.load(), .default) let kept = try String(contentsOf: url, encoding: .utf8) - #expect(kept == "{ not json") + XCTAssertEqual(kept, "{ not json") } - @Test("Corrupt file with throwCorrupt throws and leaves bytes") - func corruptThrows() throws { + func testCorruptThrows() throws { let url = tempURL() try "not json".write(to: url, atomically: true, encoding: .utf8) let store = JSONFileStore<[Int]>( @@ -42,15 +38,12 @@ struct JSONFileStoreTests { corrupt: .throwCorrupt, defaultValue: { [] } ) - #expect(throws: DecodingError.self) { - _ = try store.load() - } + XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) } let kept = try String(contentsOf: url, encoding: .utf8) - #expect(kept == "not json") + XCTAssertEqual(kept, "not json") } - @Test("Pretty sorted keys") - func prettySorted() throws { + func testPrettySorted() throws { let url = tempURL() let store = JSONFileStore( fileURL: url, @@ -59,8 +52,8 @@ struct JSONFileStoreTests { ) try store.save(.default) let text = try String(contentsOf: url, encoding: .utf8) - #expect(text.contains("\n")) - #expect(text.contains("\"delta_e_good_max\"")) + XCTAssertTrue(text.contains("\n")) + XCTAssertTrue(text.contains("\"delta_e_good_max\"")) // Lexical key sorting: ascending order of top-level keys. let keys = [ "ask_before_overwrite_profile", @@ -75,7 +68,7 @@ struct JSONFileStoreTests { var lastIndex = text.startIndex for key in keys { guard let range = text.range(of: "\"\(key)\"", range: lastIndex.. URL { let dir = FileManager.default.temporaryDirectory @@ -119,58 +101,52 @@ struct PresetStoreTests { return dir.appendingPathComponent("settings.json") } - @Test("CRUD + export/import round-trip") - func crud() throws { + func testCrud() throws { let url = try tempSettingsURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150) try store.saveCustom(p) - #expect(store.customs().count == 1) - #expect(store.all().count == 5) + XCTAssertEqual(store.customs().count, 1) + XCTAssertEqual(store.all().count, 5) p.name = "Renamed" try store.saveCustom(p) - #expect(store.customs().count == 1) - #expect(store.customs()[0].name == "Renamed") + XCTAssertEqual(store.customs().count, 1) + XCTAssertEqual(store.customs()[0].name, "Renamed") let data = try store.export(p) let imported = try store.import(data) - #expect(imported.name == "Renamed") - #expect(imported.dpi == 150) + XCTAssertEqual(imported.name, "Renamed") + XCTAssertEqual(imported.dpi, 150) - #expect(try store.deleteCustom(id: "custom-x")) - #expect(store.customs().isEmpty) - #expect(try !store.deleteCustom(id: "preset-std-rgb")) + XCTAssertTrue(try store.deleteCustom(id: "custom-x")) + XCTAssertTrue(store.customs().isEmpty) + XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb")) } - @Test("Import rewrites a built-in id to a fresh custom id") - func importBuiltinCollision() throws { + func testImportBuiltinCollision() throws { let url = try tempSettingsURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let data = try store.export(PresetCatalog.standardRGB) let imported = try store.import(data) - #expect(imported.id.hasPrefix("custom-")) - #expect(!PresetCatalog.isBuiltIn(imported.id)) + XCTAssertTrue(imported.id.hasPrefix("custom-")) + XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id)) } - @Test("Built-ins are immutable through saveCustom") - func builtInImmutable() throws { + func testBuiltInImmutable() throws { let url = try tempSettingsURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) var shadowed = PresetCatalog.standardRGB shadowed.name = "Hacked" - #expect(throws: PresetStore.PresetStoreError.self) { - try store.saveCustom(shadowed) - } + XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) } } } -@Suite("AppSettings preset migration") -struct PresetMigrationTests { +final class PresetMigrationTests: XCTestCase { private func tempSettingsURL() throws -> URL { let dir = FileManager.default.temporaryDirectory @@ -179,8 +155,7 @@ struct PresetMigrationTests { return dir.appendingPathComponent("settings.json") } - @Test("Legacy M1 custom_presets migrate to typed schema") - func legacyMigration() throws { + func testLegacyMigration() throws { let url = try tempSettingsURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let legacy = """ @@ -194,20 +169,19 @@ struct PresetMigrationTests { try legacy.write(to: url) let settings = SettingsStore(fileURL: url).load() - #expect(settings.customPresets.count == 1) + XCTAssertEqual(settings.customPresets.count, 1) let p = settings.customPresets[0] - #expect(p.name == "Old One") - #expect(p.id.hasPrefix("custom-0-")) - #expect(p.colourSpace == "cmyk") - #expect(p.patchCount == 900) - #expect(p.dpi == 150) - #expect(p.bitDepth == 16) - #expect(p.instrument == "p3") - #expect(p.pageSize == "A3") + XCTAssertEqual(p.name, "Old One") + XCTAssertTrue(p.id.hasPrefix("custom-0-")) + XCTAssertEqual(p.colourSpace, "cmyk") + XCTAssertEqual(p.patchCount, 900) + XCTAssertEqual(p.dpi, 150) + XCTAssertEqual(p.bitDepth, 16) + XCTAssertEqual(p.instrument, "p3") + XCTAssertEqual(p.pageSize, "A3") } - @Test("Typed presets load and re-save as the typed schema") - func typedRoundTrip() throws { + func testTypedRoundTrip() throws { let url = try tempSettingsURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = SettingsStore(fileURL: url) @@ -215,50 +189,45 @@ struct PresetMigrationTests { s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)] try store.save(s) let loaded = store.load() - #expect(loaded.customPresets.first?.patchCount == 700) + XCTAssertEqual(loaded.customPresets.first?.patchCount, 700) } - @Test("Draft preset dpi=150 survives Codable + settings round-trip") - func draftDPI() throws { + func testDraftDPI() throws { let url = try tempSettingsURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let data = try store.export(PresetCatalog.draftRGB) let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] - #expect(obj["dpi"] as? Int == 150) + XCTAssertEqual(obj["dpi"] as? Int, 150) let back = try store.import(data) - #expect(back.dpi == 150) + XCTAssertEqual(back.dpi, 150) } } -@Suite("Preset mapping") -struct PresetMappingTests { - @Test("Draft 150 DPI maps into PrinttargConfig") - func draftDpi() { +final class PresetMappingTests: XCTestCase { + func testDraftDpi() { let cfg = PrinttargConfig( preset: PresetCatalog.draftRGB, basename: "t", workingDirectory: nil, calibrationFile: nil ) - #expect(cfg.dpi == 150) - #expect(cfg.layoutOrder == .deterministic) + XCTAssertEqual(cfg.dpi, 150) + XCTAssertEqual(cfg.layoutOrder, .deterministic) } - @Test("Nil optional targen fields stay nil") - func optionalNil() { + func testOptionalNil() { let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) - #expect(cfg.greySteps == nil) - #expect(cfg.singleChannelSteps == nil) - #expect(cfg.neutralSteps == nil) - #expect(cfg.totalInkLimit == nil) - #expect(cfg.darkEmphasis == nil) - #expect(cfg.devicePower == nil) + XCTAssertNil(cfg.greySteps) + XCTAssertNil(cfg.singleChannelSteps) + XCTAssertNil(cfg.neutralSteps) + XCTAssertNil(cfg.totalInkLimit) + XCTAssertNil(cfg.darkEmphasis) + XCTAssertNil(cfg.devicePower) } - @Test("Custom page and FWA survive a config round-trip") - func roundTripConfigs() { + func testRoundTripConfigs() { var preset = PresetCatalog.highQualityCMYK preset.pageSize = "210x297" preset.colprofFwa = "D50" @@ -268,9 +237,9 @@ struct PresetMappingTests { preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil ) let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil) - #expect(printtarg.pageSize == .custom) - #expect(printtarg.customPageWidth == 210) - #expect(colprof.fwa == "D50") + XCTAssertEqual(printtarg.pageSize, .custom) + XCTAssertEqual(printtarg.customPageWidth, 210) + XCTAssertEqual(colprof.fwa, "D50") let back = ProfilingPreset( id: preset.id, name: preset.name, @@ -281,15 +250,14 @@ struct PresetMappingTests { calibrationFile: preset.calibrationFile, applyCalibration: preset.applyCalibration ) - #expect(back.dpi == preset.dpi) - #expect(back.colourSpace == "cmyk") - #expect(back.pageSize == "210x297") - #expect(back.colprofFwa == "D50") - #expect(back.greySteps == nil) + XCTAssertEqual(back.dpi, preset.dpi) + XCTAssertEqual(back.colourSpace, "cmyk") + XCTAssertEqual(back.pageSize, "210x297") + XCTAssertEqual(back.colprofFwa, "D50") + XCTAssertNil(back.greySteps) } - @Test("Full preset round-trips through all three configs with every field asserted") - func fullRoundTrip() { + func testFullRoundTrip() { let preset = ProfilingPreset( id: "custom-full", name: "Full", @@ -328,21 +296,21 @@ struct PresetMappingTests { ) let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil) - #expect(targen.colourSpace == .cmyk) - #expect(targen.patchCount == 1500) - #expect(targen.whitePatches == 6) - #expect(targen.blackPatches == 8) - #expect(targen.greySteps == 9) - #expect(targen.singleChannelSteps == 7) - #expect(targen.neutralSteps == 4) - #expect(targen.neutralConcentration == 0.7) - #expect(targen.preconditioningProfile == "/tmp/pre.icm") - #expect(targen.ofpsHighQuality == true) - #expect(targen.ofpsAdaptation == 0.2) - #expect(targen.fullSpreadAlgorithm == .uniformRandom) - #expect(targen.totalInkLimit == 280) - #expect(targen.darkEmphasis == 1.3) - #expect(targen.devicePower == 1.2) + XCTAssertEqual(targen.colourSpace, .cmyk) + XCTAssertEqual(targen.patchCount, 1500) + XCTAssertEqual(targen.whitePatches, 6) + XCTAssertEqual(targen.blackPatches, 8) + XCTAssertEqual(targen.greySteps, 9) + XCTAssertEqual(targen.singleChannelSteps, 7) + XCTAssertEqual(targen.neutralSteps, 4) + XCTAssertEqual(targen.neutralConcentration, 0.7) + XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm") + XCTAssertEqual(targen.ofpsHighQuality, true) + XCTAssertEqual(targen.ofpsAdaptation, 0.2) + XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom) + XCTAssertEqual(targen.totalInkLimit, 280) + XCTAssertEqual(targen.darkEmphasis, 1.3) + XCTAssertEqual(targen.devicePower, 1.2) let printtarg = PrinttargConfig( preset: preset, @@ -350,25 +318,25 @@ struct PresetMappingTests { workingDirectory: nil, calibrationFile: preset.calibrationFile ) - #expect(printtarg.instrument == .p3) - #expect(printtarg.pageSize == .custom) - #expect(printtarg.customPageWidth == 250) - #expect(printtarg.customPageHeight == 300) - #expect(printtarg.bitDepth == .sixteen) - #expect(printtarg.dpi == 360) - #expect(printtarg.layoutOrder == .customSeed) - #expect(printtarg.customSeed == 42) - #expect(printtarg.calibrationFile == "/tmp/a.cal") + XCTAssertEqual(printtarg.instrument, .p3) + XCTAssertEqual(printtarg.pageSize, .custom) + XCTAssertEqual(printtarg.customPageWidth, 250) + XCTAssertEqual(printtarg.customPageHeight, 300) + XCTAssertEqual(printtarg.bitDepth, .sixteen) + XCTAssertEqual(printtarg.dpi, 360) + XCTAssertEqual(printtarg.layoutOrder, .customSeed) + XCTAssertEqual(printtarg.customSeed, 42) + XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal") let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil) - #expect(colprof.algorithm == "x") - #expect(colprof.quality == "u") - #expect(colprof.intent == "p") - #expect(colprof.fwa == "D65") - #expect(colprof.illuminant == "D65") - #expect(colprof.observer == "1931_2") - #expect(colprof.inputViewingCond == "D50_2") - #expect(colprof.outputViewingCond == "D65_2") + XCTAssertEqual(colprof.algorithm, "x") + XCTAssertEqual(colprof.quality, "u") + XCTAssertEqual(colprof.intent, "p") + XCTAssertEqual(colprof.fwa, "D65") + XCTAssertEqual(colprof.illuminant, "D65") + XCTAssertEqual(colprof.observer, "1931_2") + XCTAssertEqual(colprof.inputViewingCond, "D50_2") + XCTAssertEqual(colprof.outputViewingCond, "D65_2") let back = ProfilingPreset( id: preset.id, @@ -380,117 +348,126 @@ struct PresetMappingTests { calibrationFile: preset.calibrationFile, applyCalibration: preset.applyCalibration ) - #expect(back == preset) + XCTAssertEqual(back, preset) } - @Test("Every full-spread algorithm round-trips", arguments: [ - ("ofps", FullSpreadAlgorithm.ofps), - ("t", .target), - ("r", .random), - ("R", .uniformRandom), - ("q", .quasiRandom), - ("Q", .uniformQuasiRandom), - ("i", .invertedQuasiRandom), - ("I", .invertedUniformQuasiRandom) - ]) - func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) { - var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) - preset.fullSpreadAlgorithm = value - let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) - if expected == .ofps { - // ofps is the default — no flag emitted, stored value is nil. - #expect(cfg.fullSpreadAlgorithm == nil) - } else { - #expect(cfg.fullSpreadAlgorithm == expected) + func testFullSpreadAlgorithms() { + let cases: [(String, FullSpreadAlgorithm)] = [ + ("ofps", .ofps), + ("t", .target), + ("r", .random), + ("R", .uniformRandom), + ("q", .quasiRandom), + ("Q", .uniformQuasiRandom), + ("i", .invertedQuasiRandom), + ("I", .invertedUniformQuasiRandom) + ] + for (value, expected) in cases { + var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) + preset.fullSpreadAlgorithm = value + let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) + if expected == .ofps { + // ofps is the default — no flag emitted, stored value is nil. + XCTAssertNil(cfg.fullSpreadAlgorithm) + } else { + XCTAssertEqual(cfg.fullSpreadAlgorithm, expected) + } + let back = ProfilingPreset( + id: "x", name: "n", description: "", + targen: cfg, + printtarg: PrinttargConfig( + preset: preset, basename: "t", + workingDirectory: nil, calibrationFile: nil + ), + colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil), + calibrationFile: nil, + applyCalibration: nil + ) + XCTAssertEqual(back.fullSpreadAlgorithm, value) } - let back = ProfilingPreset( - id: "x", name: "n", description: "", - targen: cfg, - printtarg: PrinttargConfig( - preset: preset, basename: "t", - workingDirectory: nil, calibrationFile: nil - ), - colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil), - calibrationFile: nil, - applyCalibration: nil - ) - #expect(back.fullSpreadAlgorithm == value) } - @Test("Explicit ofpsHighQuality=false is preserved, distinct from nil") - func ofpsHighQualityFalse() { + func testOfpsHighQualityFalse() { var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) preset.ofpsHighQuality = false let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) - #expect(cfg.ofpsHighQuality == false) + XCTAssertEqual(cfg.ofpsHighQuality, false) preset.ofpsHighQuality = nil let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) - #expect(nilCfg.ofpsHighQuality == nil) + XCTAssertNil(nilCfg.ofpsHighQuality) } - @Test("noRandomize/seed layout mapping rules", arguments: [ - (true, nil, LayoutOrder.raster, 1), - (true, 7, .raster, 7), - (false, nil, .deterministic, 1), - (false, 1, .deterministic, 1), - (nil, 1, .deterministic, 1), - (false, 5, .customSeed, 5) - ] as [(Bool?, Int?, LayoutOrder, Int)]) - func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) { - var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) - preset.noRandomize = noRandomize - preset.randomSeed = seed - let cfg = PrinttargConfig( - preset: preset, basename: "t", - workingDirectory: nil, calibrationFile: nil - ) - #expect(cfg.layoutOrder == layout) - #expect(cfg.customSeed == expectedSeed) + func testLayoutMapping() { + let cases: [(Bool?, Int?, LayoutOrder, Int)] = [ + (true, nil, .raster, 1), + (true, 7, .raster, 7), + (false, nil, .deterministic, 1), + (false, 1, .deterministic, 1), + (nil, 1, .deterministic, 1), + (false, 5, .customSeed, 5) + ] + for (noRandomize, seed, layout, expectedSeed) in cases { + var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) + preset.noRandomize = noRandomize + preset.randomSeed = seed + let cfg = PrinttargConfig( + preset: preset, basename: "t", + workingDirectory: nil, calibrationFile: nil + ) + XCTAssertEqual(cfg.layoutOrder, layout) + XCTAssertEqual(cfg.customSeed, expectedSeed) + } } - @Test("Custom page fallback matrix", arguments: [ - ("250x300", PageSize.custom, 250.0, 300.0), - ("50x50", .custom, 50.0, 50.0), - ("foo", .a4, 210.0, 297.0), - ("30x40", .a4, 210.0, 297.0), - ("210x", .a4, 210.0, 297.0) - ] as [(String, PageSize, Double, Double)]) - func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) { - var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) - preset.pageSize = raw - let cfg = PrinttargConfig( - preset: preset, basename: "t", - workingDirectory: nil, calibrationFile: nil - ) - #expect(cfg.pageSize == page) - #expect(cfg.customPageWidth == w) - #expect(cfg.customPageHeight == h) + func testCustomPageFallback() { + let cases: [(String, PageSize, Double, Double)] = [ + ("250x300", .custom, 250.0, 300.0), + ("50x50", .custom, 50.0, 50.0), + ("foo", .a4, 210.0, 297.0), + ("30x40", .a4, 210.0, 297.0), + ("210x", .a4, 210.0, 297.0) + ] + for (raw, page, w, h) in cases { + var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) + preset.pageSize = raw + let cfg = PrinttargConfig( + preset: preset, basename: "t", + workingDirectory: nil, calibrationFile: nil + ) + XCTAssertEqual(cfg.pageSize, page) + XCTAssertEqual(cfg.customPageWidth, w) + XCTAssertEqual(cfg.customPageHeight, h) + } } - @Test("FWA preset value → selection matrix", arguments: [ - (nil, ColprofFwaSelection.none), - ("none", .none), - ("NONE", .none), - ("", .empty), - ("D50", .D50), - ("d50", .D50), - ("D65", .D65), - ("d65", .D65), - ("/tmp/fwa.sp", .custom) - ] as [(String?, ColprofFwaSelection)]) - func fwaToSelection(raw: String?, expected: ColprofFwaSelection) { - #expect(ColprofFwaSelection(presetValue: raw) == expected) + func testFwaToSelection() { + let cases: [(String?, ColprofFwaSelection)] = [ + (nil, .none), + ("none", .none), + ("NONE", .none), + ("", .empty), + ("D50", .D50), + ("d50", .D50), + ("D65", .D65), + ("d65", .D65), + ("/tmp/fwa.sp", .custom) + ] + for (raw, expected) in cases { + XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected) + } } - @Test("FWA selection → preset value matrix", arguments: [ - (ColprofFwaSelection.none, nil), - (.empty, ""), - (.D50, "D50"), - (.D65, "D65"), - (.custom, "/tmp/fwa.sp") - ] as [(ColprofFwaSelection, String?)]) - func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) { - #expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected) + func testFwaToPresetValue() { + let cases: [(ColprofFwaSelection, String?)] = [ + (.none, nil), + (.empty, ""), + (.D50, "D50"), + (.D65, "D65"), + (.custom, "/tmp/fwa.sp") + ] + for (selection, expected) in cases { + XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected) + } } } diff --git a/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift b/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift index 30a96e4..08806f1 100644 --- a/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift +++ b/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift @@ -1,21 +1,19 @@ -import Testing import Foundation +import XCTest @testable import ICCeryCore @testable import ICCery /// Issue #82 — preset application through the live view models, under an /// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager). -@Suite("PresetViewModelMapping") @MainActor -struct PresetViewModelMappingTests { +final class PresetViewModelMappingTests: XCTestCase { private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) { let env = try TestAppEnvironment.make() return (env, TargetWorkflowViewModel(environment: env.environment)) } - @Test("Applying a nil-FWA preset after a custom FWA clears the stale path") - func nilFwaClearsCustomPath() throws { + func testNilFwaClearsCustomPath() throws { let (env, vm) = try makeWorkflow() defer { env.cleanup() } @@ -24,18 +22,17 @@ struct PresetViewModelMappingTests { colprofFwa: "/tmp/fwa.sp" ) vm.applyPreset(customPreset) - #expect(vm.profile.fwaSelection == .custom) - #expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp") + XCTAssertEqual(vm.profile.fwaSelection, .custom) + XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp") customPreset.colprofFwa = nil vm.applyPreset(customPreset) - #expect(vm.profile.fwaSelection == .none) - #expect(vm.profile.fwaCustomPath == "") - #expect(vm.profile.fwaValue == nil) + XCTAssertEqual(vm.profile.fwaSelection, .none) + XCTAssertEqual(vm.profile.fwaCustomPath, "") + XCTAssertNil(vm.profile.fwaValue) } - @Test("Custom FWA preset path survives the round-trip to colprof_fwa") - func customFwaRoundTrip() throws { + func testCustomFwaRoundTrip() throws { let (env, vm) = try makeWorkflow() defer { env.cleanup() } @@ -44,13 +41,12 @@ struct PresetViewModelMappingTests { colprofFwa: "/tmp/other.sp" ) vm.applyPreset(preset) - #expect(vm.profile.fwaSelection == .custom) - #expect(vm.profile.fwaCustomPath == "/tmp/other.sp") - #expect(vm.profile.fwaValue == "/tmp/other.sp") + XCTAssertEqual(vm.profile.fwaSelection, .custom) + XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp") + XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp") } - @Test("Preset calibration reaches Stage 2 instead of stale live state") - func presetCalibrationReachesStage2() throws { + func testPresetCalibrationReachesStage2() throws { let (env, vm) = try makeWorkflow() defer { env.cleanup() } @@ -65,13 +61,12 @@ struct PresetViewModelMappingTests { ) vm.applyPreset(preset) - #expect(vm.profile.applyCalibration) - #expect(vm.profile.calibrationFile == "/tmp/preset.cal") - #expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal") + XCTAssertTrue(vm.profile.applyCalibration) + XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal") + XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal") } - @Test("Preset with calibration disabled clears Stage 2 calibration") - func disabledCalibrationClearsStage2() throws { + func testDisabledCalibrationClearsStage2() throws { let (env, vm) = try makeWorkflow() defer { env.cleanup() } @@ -85,12 +80,11 @@ struct PresetViewModelMappingTests { ) vm.applyPreset(preset) - #expect(!vm.profile.applyCalibration) - #expect(vm.buildPrinttargConfig().calibrationFile == nil) + XCTAssertFalse(vm.profile.applyCalibration) + XCTAssertNil(vm.buildPrinttargConfig().calibrationFile) } - @Test("Preset Stage 1/2 form fields apply to the live form") - func formFieldsApply() throws { + func testFormFieldsApply() throws { let (env, vm) = try makeWorkflow() defer { env.cleanup() } @@ -106,22 +100,22 @@ struct PresetViewModelMappingTests { ) vm.applyPreset(preset) - #expect(vm.colourSpace == .cmyk) - #expect(vm.effectivePatchCount == 1500) - #expect(vm.whitePatches == 6) - #expect(vm.blackPatches == 8) - #expect(vm.greyStepsEnabled && vm.greySteps == 9) - #expect(vm.algorithm == .random) - #expect(vm.tiffDpi == 150) - #expect(vm.pageSize == .custom) - #expect(vm.customPageW == 250 && vm.customPageH == 300) - #expect(vm.selectedPresetID == "c-form") + XCTAssertEqual(vm.colourSpace, .cmyk) + XCTAssertEqual(vm.effectivePatchCount, 1500) + XCTAssertEqual(vm.whitePatches, 6) + XCTAssertEqual(vm.blackPatches, 8) + XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9) + XCTAssertEqual(vm.algorithm, .random) + XCTAssertEqual(vm.tiffDpi, 150) + XCTAssertEqual(vm.pageSize, .custom) + XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300) + XCTAssertEqual(vm.selectedPresetID, "c-form") // Disabled advanced controls stay nil in the snapshot, not // numeric sentinels. preset.greySteps = nil vm.applyPreset(preset) - #expect(!vm.greyStepsEnabled) - #expect(vm.buildTargenConfig().greySteps == nil) + XCTAssertFalse(vm.greyStepsEnabled) + XCTAssertNil(vm.buildTargenConfig().greySteps) } } diff --git a/Tests/ICCeryCoreTests/PrintPanelTests.swift b/Tests/ICCeryCoreTests/PrintPanelTests.swift index 3bdc0f2..5a92936 100644 --- a/Tests/ICCeryCoreTests/PrintPanelTests.swift +++ b/Tests/ICCeryCoreTests/PrintPanelTests.swift @@ -1,13 +1,12 @@ -import Testing import Foundation +import XCTest @testable import ICCeryCore @testable import ICCery /// Issue 13 — panel outcome mapping (cancel → nil, ok → result). /// The real `NSPrintPanel` is never run in tests; these exercise the /// `UITestHooks` seam the UI tests rely on. -@Suite("PrintPanelStub") -struct PrintPanelStubTests { +final class PrintPanelStubTests: XCTestCase { private func withEnv( _ vars: [String: String?], @@ -28,19 +27,17 @@ struct PrintPanelStubTests { try body() } - @Test("Cancel returns nil — not an error") - func cancelIsNil() throws { + func testCancelIsNil() throws { try withEnv([ "ICCERY_UI_TESTING": "1", "ICCERY_TEST_PRINT_PANEL": "cancel", ]) { - #expect(UITestHooks.printPanelStubbed) - #expect(UITestHooks.printPanelResult(forQueue: "q") == nil) + XCTAssertTrue(UITestHooks.printPanelStubbed) + XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q")) } } - @Test("OK returns captured options + selected printer") - func okResult() throws { + func testOkResult() throws { try withEnv([ "ICCERY_UI_TESTING": "1", "ICCERY_TEST_PRINT_PANEL": "ok", @@ -48,15 +45,14 @@ struct PrintPanelStubTests { "ICCERY_TEST_PANEL_PRINTER": "Other_Queue", ]) { let result = UITestHooks.printPanelResult(forQueue: "q") - #expect(result?.selectedPrinter == "Other_Queue") - #expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear") - #expect(result?.options.mediaType == "Photo") - #expect(result?.options.ppdUncorrectedPassthrough == true) + XCTAssertEqual(result?.selectedPrinter, "Other_Queue") + XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear") + XCTAssertEqual(result?.options.mediaType, "Photo") + XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true) } } - @Test("OK defaults selected printer to the opened queue") - func okDefaultsPrinter() throws { + func testOkDefaultsPrinter() throws { try withEnv([ "ICCERY_UI_TESTING": "1", "ICCERY_TEST_PRINT_PANEL": "ok", @@ -64,8 +60,8 @@ struct PrintPanelStubTests { "ICCERY_TEST_PANEL_PRINTER": nil, ]) { let result = UITestHooks.printPanelResult(forQueue: "My_Queue") - #expect(result?.selectedPrinter == "My_Queue") - #expect(result?.options.cupsOptions == nil) + XCTAssertEqual(result?.selectedPrinter, "My_Queue") + XCTAssertNil(result?.options.cupsOptions) } } } diff --git a/Tests/ICCeryCoreTests/PrinttargTests.swift b/Tests/ICCeryCoreTests/PrinttargTests.swift index b147afb..a64df4c 100644 --- a/Tests/ICCeryCoreTests/PrinttargTests.swift +++ b/Tests/ICCeryCoreTests/PrinttargTests.swift @@ -1,4 +1,3 @@ -import Testing import XCTest import Foundation @testable import ICCeryCore @@ -257,8 +256,7 @@ final class PrinttargManifestTests: XCTestCase { } } -@Suite("ArgyllRunner Printtarg") -struct ArgyllRunnerPrinttargTests { +final class ArgyllRunnerPrinttargTests: XCTestCase { private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL { let dir = FileManager.default.temporaryDirectory @@ -310,8 +308,7 @@ struct ArgyllRunnerPrinttargTests { try Data(bytes).write(to: url) } - @Test("Successful printtarg emits .ti2 + manifest + PNG previews") - func success() async throws { + func testSuccess() async throws { let dir = try makeFixture(""" #!/bin/sh last="" @@ -330,18 +327,17 @@ struct ArgyllRunnerPrinttargTests { processManager: ProcessManager(), binaryResolver: resolver) let config = PrinttargConfig(basename: "pt", workingDirectory: dir) let result = try await runner.runPrinttarg(config: config) - #expect(result.ti2URL.lastPathComponent == "pt.ti2") - #expect(result.manifest.pages.count == 1) - #expect(result.pages.count == 1) + XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2") + XCTAssertEqual(result.manifest.pages.count, 1) + XCTAssertEqual(result.pages.count, 1) let png = result.pages[0].previewPNG - #expect(png != nil) + XCTAssertNotNil(png) if let png { - #expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A])) + XCTAssertEqual(png.prefix(8), Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A])) } } - @Test("Non-zero exit throws toolFailed and stays on stage") - func failure() async throws { + func testFailure() async throws { let dir = try makeFixture(""" #!/bin/sh echo "oops" >&2 @@ -351,15 +347,16 @@ struct ArgyllRunnerPrinttargTests { let runner = ArgyllRunner( processManager: ProcessManager(), binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) - await #expect(throws: ArgyllRunnerError.toolFailed( - tool: "printtarg", code: 3, logs: ["oops"])) { + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { try await runner.runPrinttarg( config: PrinttargConfig(basename: "x", workingDirectory: dir)) + } errorHandler: { error in + XCTAssertEqual(error, .toolFailed( + tool: "printtarg", code: 3, logs: ["oops"])) } } - @Test("Exit 0 without manifest → malformedManifest") - func noManifest() async throws { + func testNoManifest() async throws { let dir = try makeFixture(""" #!/bin/sh last="" @@ -372,14 +369,13 @@ struct ArgyllRunnerPrinttargTests { let runner = ArgyllRunner( processManager: ProcessManager(), binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) - await #expect(throws: ArgyllRunnerError.self) { + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { try await runner.runPrinttarg( config: PrinttargConfig(basename: "x", workingDirectory: dir)) } } - @Test("Exit 0 without .ti2 → missingArtefact") - func noTi2() async throws { + func testNoTi2() async throws { let dir = try makeFixture(""" #!/bin/sh printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' @@ -389,14 +385,13 @@ struct ArgyllRunnerPrinttargTests { let runner = ArgyllRunner( processManager: ProcessManager(), binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) - await #expect(throws: ArgyllRunnerError.self) { + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { try await runner.runPrinttarg( config: PrinttargConfig(basename: "x", workingDirectory: dir)) } } - @Test("Deterministic config produces byte-identical .ti2") - func determinism() async throws { + func testDeterminism() async throws { let dir = try makeFixture(""" #!/bin/sh last="" @@ -416,6 +411,6 @@ struct ArgyllRunnerPrinttargTests { config: PrinttargConfig(basename: "b", workingDirectory: dir)) let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2")) let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2")) - #expect(d1 == d2) + XCTAssertEqual(d1, d2) } } diff --git a/Tests/ICCeryCoreTests/ProcessManagerTests.swift b/Tests/ICCeryCoreTests/ProcessManagerTests.swift index d3a3f3a..261b5a9 100644 --- a/Tests/ICCeryCoreTests/ProcessManagerTests.swift +++ b/Tests/ICCeryCoreTests/ProcessManagerTests.swift @@ -1,12 +1,11 @@ -import Testing import XCTest import Foundation @testable import ICCeryCore /// Helpers shared across ProcessManager tests. Fixture binaries are shell /// scripts written to a temp dir — no resource bundling required. -@Suite("ProcessManager", .serialized) -struct ProcessManagerTests { +/// XCTest executes test methods serially by default. +final class ProcessManagerTests: XCTestCase { // MARK: - Fixture plumbing @@ -44,7 +43,7 @@ struct ProcessManagerTests { if box.finish() { cont.resume(returning: box.events) } } Task { - try? await Task.sleep(for: .seconds(timeout)) + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) if box.finish() { cont.resume(returning: box.events) } } } @@ -85,7 +84,7 @@ struct ProcessManagerTests { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if exitCount(in: box) > 0 { return true } - try? await Task.sleep(for: .milliseconds(10)) + try? await Task.sleep(nanoseconds: 10_000_000) } return false } @@ -94,7 +93,7 @@ struct ProcessManagerTests { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if FileManager.default.fileExists(atPath: url.path) { return true } - try? await Task.sleep(for: .milliseconds(10)) + try? await Task.sleep(nanoseconds: 10_000_000) } return false } @@ -107,14 +106,14 @@ struct ProcessManagerTests { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if await manager.isRunning(id) { return true } - try? await Task.sleep(for: .milliseconds(10)) + try? await Task.sleep(nanoseconds: 10_000_000) } return false } // MARK: - Tests - @Test func streamsStdoutAndEmitsExit() async throws { + func testStreamsStdoutAndEmitsExit() async throws { let pm = ProcessManager() let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n") async let events = collect(pm, id: "t1") @@ -123,21 +122,21 @@ struct ProcessManagerTests { let lines = evs.compactMap { e -> String? in if case .stdout(_, let l) = e { return l }; return nil } - #expect(lines == ["hello", "world"]) - #expect(evs.contains(.exit(id: "t1", code: 0))) + XCTAssertEqual(lines, ["hello", "world"]) + XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0))) } - @Test func routesStderrSeparately() async throws { + func testRoutesStderrSeparately() async throws { let pm = ProcessManager() let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n") async let evs = collect(pm, id: "t2") try await pm.runStreaming(id: "t2", binary: bin, arguments: []) let events = await evs - #expect(events.contains(.stdout(id: "t2", line: "out"))) - #expect(events.contains(.stderr(id: "t2", line: "oops"))) + XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out"))) + XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops"))) } - @Test func stripsRowColorsJSONPrefix() async throws { + func testStripsRowColorsJSONPrefix() async throws { let pm = ProcessManager() let bin = try script( "rows.sh", @@ -150,21 +149,22 @@ struct ProcessManagerTests { if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } return nil } - #expect(rows == ["{\"row\":1}"]) - #expect(events.contains(.stdout(id: "t3", line: "plain"))) + XCTAssertEqual(rows, ["{\"row\":1}"]) + XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain"))) // Prefixed lines must not leak into stdout. - #expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}"))) + XCTAssertFalse(events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}"))) } - @Test func unterminatedTailFlushesOnExit() async throws { + func testUnterminatedTailFlushesOnExit() async throws { let pm = ProcessManager() let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n") async let evs = collect(pm, id: "t4") try await pm.runStreaming(id: "t4", binary: bin, arguments: []) - #expect(await evs.contains(.stdout(id: "t4", line: "no-newline"))) + let t4SawTail = await evs.contains(.stdout(id: "t4", line: "no-newline")) + XCTAssertTrue(t4SawTail) } - @Test func stdinRoundTrip() async throws { + func testStdinRoundTrip() async throws { let pm = ProcessManager() // Read two lines then exit naturally — a killed sh would lose its // buffered stdio output, which is exactly the chartread pattern. @@ -177,21 +177,23 @@ struct ProcessManagerTests { try await pm.sendStdin(id: "t5", text: " \n") try await pm.sendStdin(id: "t5", text: "d\n") let events = await evs - #expect(events.contains(.stdout(id: "t5", line: "got: "))) - #expect(events.contains(.stdout(id: "t5", line: "got:d"))) + XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: "))) + XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d"))) } - @Test func duplicateIDRejected() async throws { + func testDuplicateIDRejected() async throws { let pm = ProcessManager() let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n") try await pm.runStreaming(id: "t6", binary: bin, arguments: []) - await #expect(throws: ProcessError.duplicateID("t6")) { + await assertAsyncThrows(expectedType: ProcessError.self) { try await pm.runStreaming(id: "t6", binary: bin, arguments: []) + } errorHandler: { error in + XCTAssertEqual(error, .duplicateID("t6")) } await pm.kill(id: "t6") } - @Test func killEmitsExitAndClosesStdin() async throws { + func testKillEmitsExitAndClosesStdin() async throws { let pm = ProcessManager() let bin = try script("slow2.sh", "#!/bin/sh\ncat\n") async let evs = collect(pm, id: "t7") @@ -200,49 +202,51 @@ struct ProcessManagerTests { let events = await evs // exit emitted exactly once let exits = events.filter { if case .exit = $0 { return true }; return false } - #expect(exits.count == 1) - await #expect(throws: ProcessError.unknownID("t7")) { + XCTAssertEqual(exits.count, 1) + await assertAsyncThrows(expectedType: ProcessError.self) { try await pm.sendStdin(id: "t7", text: "d\n") + } errorHandler: { error in + XCTAssertEqual(error, .unknownID("t7")) } } - @Test func killAllCountsSignaled() async throws { + func testKillAllCountsSignaled() async throws { let pm = ProcessManager() let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n") try await pm.runStreaming(id: "a", binary: bin, arguments: []) try await pm.runStreaming(id: "b", binary: bin, arguments: []) let count = await pm.killAll() - #expect(count == 2) + XCTAssertEqual(count, 2) } - @Test func capturedRunReturnsBothStreams() async throws { + func testCapturedRunReturnsBothStreams() async throws { let pm = ProcessManager() let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n") let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: []) - #expect(result.stdout.contains("out-data")) - #expect(result.stderr.contains("err-data")) - #expect(result.exitCode == 3) + XCTAssertTrue(result.stdout.contains("out-data")) + XCTAssertTrue(result.stderr.contains("err-data")) + XCTAssertEqual(result.exitCode, 3) } - @Test func capturedRunFastExit() async throws { + func testCapturedRunFastExit() async throws { let pm = ProcessManager() let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n") let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: []) - #expect(result.exitCode == 7) - #expect(result.stdout == "") - #expect(result.stderr == "") + XCTAssertEqual(result.exitCode, 7) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(result.stderr, "") } - @Test func capturedRunStderrOnly() async throws { + func testCapturedRunStderrOnly() async throws { let pm = ProcessManager() let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n") let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: []) - #expect(result.exitCode == 1) - #expect(result.stdout == "") - #expect(result.stderr.contains("mock lp failure")) + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, "") + XCTAssertTrue(result.stderr.contains("mock lp failure")) } - @Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws { + func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws { let pm = ProcessManager() // 5000 lines each stream exceeds the 64 KiB pipe buffer. let bin = try script( @@ -250,26 +254,29 @@ struct ProcessManagerTests { "#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n" ) let result = try await pm.runCaptured(id: "big", binary: bin, arguments: []) - #expect(result.stdout.contains("out-4999")) - #expect(result.stderr.contains("err-4999")) + XCTAssertTrue(result.stdout.contains("out-4999")) + XCTAssertTrue(result.stderr.contains("err-4999")) } - @Test func argyllEnvVarIsSet() async throws { + func testArgyllEnvVarIsSet() async throws { let pm = ProcessManager() let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n") async let evs = collect(pm, id: "t10") try await pm.runStreaming(id: "t10", binary: bin, arguments: []) - #expect(await evs.contains(.stdout(id: "t10", line: "ANI=1"))) + let t10SawEnv = await evs.contains(.stdout(id: "t10", line: "ANI=1")) + XCTAssertTrue(t10SawEnv) } - @Test func unknownIDStdinThrows() async throws { + func testUnknownIDStdinThrows() async throws { let pm = ProcessManager() - await #expect(throws: ProcessError.unknownID("nope")) { + await assertAsyncThrows(expectedType: ProcessError.self) { try await pm.sendStdin(id: "nope", text: "d\n") + } errorHandler: { error in + XCTAssertEqual(error, .unknownID("nope")) } } - @Test func explicitPartialFlushEmitsRowColorsJSON() async throws { + func testExplicitPartialFlushEmitsRowColorsJSON() async throws { let pm = ProcessManager() let marker = Self.fixtureDir .appendingPathComponent("partial-row-ready-\(UUID().uuidString)") @@ -280,7 +287,8 @@ struct ProcessManagerTests { let box = Box() let observer = observe(pm, id: "t11", into: box) try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path]) - #expect(await waitForFile(marker)) + let markerReady = await waitForFile(marker) + XCTAssertTrue(markerReady) // Retry the flush so the pipe-ingest task can win the actor race // on a loaded host; the first successful flush emits the row. var flushed = false @@ -290,24 +298,25 @@ struct ProcessManagerTests { flushed = true break } - try await Task.sleep(for: .milliseconds(20)) + try await Task.sleep(nanoseconds: 20_000_000) } - #expect(flushed) + XCTAssertTrue(flushed) await pm.kill(id: "t11") - #expect(await waitForExit(in: box)) + let sawExit = await waitForExit(in: box) + XCTAssertTrue(sawExit) observer.cancel() let events = box.events let rows = events.compactMap { e -> String? in if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } return nil } - #expect(rows == ["{\"row\":9}"]) + XCTAssertEqual(rows, ["{\"row\":9}"]) // Prefixed tails must not leak into stdout, even via finalize. - #expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}"))) - #expect(exitCount(in: box) == 1) + XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}"))) + XCTAssertEqual(exitCount(in: box), 1) } - @Test func unterminatedRowTailFinalizesAsJSONRow() async throws { + func testUnterminatedRowTailFinalizesAsJSONRow() async throws { let pm = ProcessManager() let bin = try script( "row-tail.sh", @@ -316,68 +325,70 @@ struct ProcessManagerTests { let box = Box() let observer = observe(pm, id: "t12", into: box) try await pm.runStreaming(id: "t12", binary: bin, arguments: []) - #expect(await waitForExit(in: box)) + let sawExit = await waitForExit(in: box) + XCTAssertTrue(sawExit) observer.cancel() let events = box.events let rows = events.compactMap { e -> String? in if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } return nil } - #expect(rows == ["{\"row\":42}"]) - #expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}"))) + XCTAssertEqual(rows, ["{\"row\":42}"]) + XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}"))) let rowIndex = events.firstIndex { if case .jsonRow = $0 { return true }; return false } let exitIndexes = events.indices.filter { if case .exit = events[$0] { return true }; return false } - #expect(exitIndexes.count == 1) + XCTAssertEqual(exitIndexes.count, 1) if let rowIndex, let exitIndex = exitIndexes.first { - #expect(rowIndex < exitIndex) + XCTAssertTrue(rowIndex < exitIndex) } else { - Issue.record("expected a jsonRow before the exit event") + XCTFail("expected a jsonRow before the exit event") } } - @Test func fastStreamingExitEmitsExactlyOneExit() async throws { + func testFastStreamingExitEmitsExactlyOneExit() async throws { let pm = ProcessManager() let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n") let box = Box() let observer = observe(pm, id: "t13", into: box) try await pm.runStreaming(id: "t13", binary: bin, arguments: []) - #expect(await waitForExit(in: box)) + let sawExit = await waitForExit(in: box) + XCTAssertTrue(sawExit) // The grace window must outlast the 2 s finalize watchdog so a // duplicate emission from it would be observed. - try await Task.sleep(for: .milliseconds(2500)) + try await Task.sleep(nanoseconds: 2_500_000_000) observer.cancel() - #expect(box.events == [.exit(id: "t13", code: 0)]) + XCTAssertEqual(box.events, [.exit(id: "t13", code: 0)]) } - @Test func fastCapturedExitEmitsExactlyOneExit() async throws { + func testFastCapturedExitEmitsExactlyOneExit() async throws { let pm = ProcessManager() let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n") let box = Box() let observer = observe(pm, id: "t14", into: box) let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: []) - #expect(result.exitCode == 7) + XCTAssertEqual(result.exitCode, 7) // Both the termination handler and the waitUntilExit watchdog // resume the same box; give the slower path time to fire. - try await Task.sleep(for: .milliseconds(500)) + try await Task.sleep(nanoseconds: 500_000_000) observer.cancel() - #expect(box.events == [.exit(id: "t14", code: 7)]) + XCTAssertEqual(box.events, [.exit(id: "t14", code: 7)]) } - @Test func capturedRunSetsArgyllNotInteractive() async throws { + func testCapturedRunSetsArgyllNotInteractive() async throws { let pm = ProcessManager() let bin = try script( "cap-env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n" ) let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: []) - #expect(result.stdout == "ANI=1\n") + XCTAssertEqual(result.stdout, "ANI=1\n") } - @Test func killAllTerminatesStreamingAndCapturedChildren() async throws { + func testKillAllTerminatesStreamingAndCapturedChildren() async throws { let pm = ProcessManager() let marker = Self.fixtureDir .appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)") @@ -391,21 +402,29 @@ struct ProcessManagerTests { let capTask = Task { try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path]) } - #expect(await waitForFile(marker)) - #expect(await waitForRunning(pm, id: "t16")) - #expect(await waitForRunning(pm, id: "t17")) - #expect(await pm.killAll() == 2) + let markerReady = await waitForFile(marker) + XCTAssertTrue(markerReady) + let t16Running = await waitForRunning(pm, id: "t16") + XCTAssertTrue(t16Running) + let t17Running = await waitForRunning(pm, id: "t17") + XCTAssertTrue(t17Running) + let killed = await pm.killAll() + XCTAssertEqual(killed, 2) _ = try await capTask.value - #expect(await waitForExit(in: streamBox)) - #expect(await waitForExit(in: capBox)) + let streamExit = await waitForExit(in: streamBox) + XCTAssertTrue(streamExit) + let capExit = await waitForExit(in: capBox) + XCTAssertTrue(capExit) // Grace window outlasts the streaming finalize watchdog. - try await Task.sleep(for: .milliseconds(2500)) + try await Task.sleep(nanoseconds: 2_500_000_000) streamObserver.cancel() capObserver.cancel() - #expect(!(await pm.isRunning("t16"))) - #expect(!(await pm.isRunning("t17"))) - #expect(exitCount(in: streamBox) == 1) - #expect(exitCount(in: capBox) == 1) + let t16RunningAfter = await pm.isRunning("t16") + XCTAssertFalse(t16RunningAfter) + let t17RunningAfter = await pm.isRunning("t17") + XCTAssertFalse(t17RunningAfter) + XCTAssertEqual(exitCount(in: streamBox), 1) + XCTAssertEqual(exitCount(in: capBox), 1) } } diff --git a/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift index 79ebe1d..a1edb9b 100644 --- a/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift +++ b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift @@ -1,5 +1,5 @@ import Foundation -import Testing +import XCTest @testable import ICCery /// Direct contracts for the shared logged-run helper (issue #80). @@ -7,14 +7,12 @@ import Testing /// `runLogged` owns the running-flag transition (`false → true → false`) /// and the log-reset decision; these tests pin both sides of the /// contract plus the coalesced `@MainActor` log hop. -@Suite("ProcessRunSupport runLogged") @MainActor -struct ProcessRunSupportTests { +final class ProcessRunSupportTests: XCTestCase { private struct SentinelError: Error {} - @Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved") - func successTransitions() async throws { + func testSuccessTransitions() async throws { var running: [Bool] = [] var resets = 0 var received: [String] = [] @@ -31,20 +29,19 @@ struct ProcessRunSupportTests { return 42 } - #expect(result == 42) - #expect(running == [true, false]) - #expect(resets == 1) + XCTAssertEqual(result, 42) + XCTAssertEqual(running, [true, false]) + XCTAssertEqual(resets, 1) // The sink hops back through a main-actor Task; yield until the // coalesced batch lands. for _ in 0..<200 where received.isEmpty { - try await Task.sleep(for: .milliseconds(10)) + try await Task.sleep(nanoseconds: 10_000_000) } - #expect(received == ["alpha", "beta"]) + XCTAssertEqual(received, ["alpha", "beta"]) } - @Test("Failure: running still transitions [true, false], log resets once, error is rethrown") - func failureTransitions() async throws { + func testFailureTransitions() async throws { var running: [Bool] = [] var resets = 0 @@ -56,12 +53,12 @@ struct ProcessRunSupportTests { ) { _ -> Int in throw SentinelError() } - Issue.record("Expected runLogged to rethrow") + XCTFail("Expected runLogged to rethrow") } catch is SentinelError { // Expected path. } - #expect(running == [true, false]) - #expect(resets == 1) + XCTAssertEqual(running, [true, false]) + XCTAssertEqual(resets, 1) } } diff --git a/Tests/ICCeryCoreTests/ProfileInstallerTests.swift b/Tests/ICCeryCoreTests/ProfileInstallerTests.swift index 7851582..67cb353 100644 --- a/Tests/ICCeryCoreTests/ProfileInstallerTests.swift +++ b/Tests/ICCeryCoreTests/ProfileInstallerTests.swift @@ -1,5 +1,5 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore /// A `FileManager` subclass that reports a temporary directory as the @@ -18,8 +18,7 @@ private final class TestFileManager: FileManager { } } -@Suite("ProfileInstaller") -struct ProfileInstallerTests { +final class ProfileInstallerTests: XCTestCase { private func makeTempDir() throws -> URL { let fm = FileManager.default @@ -39,8 +38,7 @@ struct ProfileInstallerTests { return url } - @Test("Installs .icc to user ColorSync folder") - func userInstall() throws { + func testUserInstall() throws { let fm = FileManager.default let tmp = try makeTempDir() let testFM = TestFileManager(home: tmp) @@ -51,15 +49,14 @@ struct ProfileInstallerTests { fileManager: testFM ) - #expect(result.registered) - #expect(!result.overwritten) - #expect(!result.renamed) - #expect(result.destPath.hasSuffix("test.icc")) - #expect(fm.fileExists(atPath: result.destPath)) + XCTAssertTrue(result.registered) + XCTAssertFalse(result.overwritten) + XCTAssertFalse(result.renamed) + XCTAssertTrue(result.destPath.hasSuffix("test.icc")) + XCTAssertTrue(fm.fileExists(atPath: result.destPath)) } - @Test("Overwrite succeeds and replaces the existing file") - func overwriteSucceeds() throws { + func testOverwriteSucceeds() throws { let fm = FileManager.default let tmp = try makeTempDir() let testFM = TestFileManager(home: tmp) @@ -70,7 +67,7 @@ struct ProfileInstallerTests { config: InstallProfileConfig(sourceURL: source), fileManager: testFM ) - #expect(!first.overwritten) + XCTAssertFalse(first.overwritten) // Change the source contents. let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) } @@ -87,15 +84,14 @@ struct ProfileInstallerTests { fileManager: testFM ) - #expect(second.overwritten) - #expect(!second.renamed) - #expect(fm.fileExists(atPath: second.destPath)) + XCTAssertTrue(second.overwritten) + XCTAssertFalse(second.renamed) + XCTAssertTrue(fm.fileExists(atPath: second.destPath)) let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath)) - #expect(Array(installed) == newBytes) + XCTAssertEqual(Array(installed), newBytes) } - @Test("Preserves .icm source extension") - func preservesIcmExtension() throws { + func testPreservesIcmExtension() throws { let tmp = try makeTempDir() let testFM = TestFileManager(home: tmp) let source = try makeSource(at: tmp, name: "m5_profile.icm") @@ -105,12 +101,11 @@ struct ProfileInstallerTests { fileManager: testFM ) - #expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm") - #expect(result.destPath.hasSuffix("m5_profile.icm")) + XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm") + XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm")) } - @Test("Rejects parent traversal in source path") - func rejectsParentTraversal() throws { + func testRejectsParentTraversal() throws { let fm = FileManager.default let tmp = try makeTempDir() @@ -125,20 +120,19 @@ struct ProfileInstallerTests { let sourceURL = tmp .appendingPathComponent("..") .appendingPathComponent(naughtyName) - #expect(fm.fileExists(atPath: sourceURL.path)) + XCTAssertTrue(fm.fileExists(atPath: sourceURL.path)) do { _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL)) - Issue.record("Expected unsafeStem error") + XCTFail("Expected unsafeStem error") } catch let error as ProfileInstallError { - if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") } + if case .unsafeStem = error { } else { XCTFail("Expected unsafeStem, got \(error)") } } catch { - Issue.record("Unexpected error type: \(error)") + XCTFail("Unexpected error type: \(error)") } } - @Test("Allows stems with consecutive dots like foo..bar") - func allowsDoubleDotStem() throws { + func testAllowsDoubleDotStem() throws { let fm = FileManager.default let tmp = try makeTempDir() let testFM = TestFileManager(home: tmp) @@ -149,23 +143,22 @@ struct ProfileInstallerTests { fileManager: testFM ) - #expect(result.destPath.hasSuffix("foo..bar.icc")) - #expect(fm.fileExists(atPath: result.destPath)) + XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc")) + XCTAssertTrue(fm.fileExists(atPath: result.destPath)) } - @Test("Rejects source files that are too small") - func rejectsSmallSource() throws { + func testRejectsSmallSource() throws { let tmp = try makeTempDir() let source = tmp.appendingPathComponent("tiny.icc") try Data(repeating: 0, count: 64).write(to: source) do { _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source)) - Issue.record("Expected sourceTooSmall error") + XCTFail("Expected sourceTooSmall error") } catch let error as ProfileInstallError { - if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") } + if case .sourceTooSmall = error { } else { XCTFail("Expected sourceTooSmall, got \(error)") } } catch { - Issue.record("Unexpected error type: \(error)") + XCTFail("Unexpected error type: \(error)") } } } diff --git a/Tests/ICCeryCoreTests/SettingsTests.swift b/Tests/ICCeryCoreTests/SettingsTests.swift index 9b86b15..c8ae705 100644 --- a/Tests/ICCeryCoreTests/SettingsTests.swift +++ b/Tests/ICCeryCoreTests/SettingsTests.swift @@ -1,5 +1,5 @@ -import Testing import Foundation +import XCTest @testable import ICCeryCore private func tempStoreURL() -> URL { @@ -8,92 +8,90 @@ private func tempStoreURL() -> URL { .appendingPathComponent("settings.json") } -@Suite("AppSettings") -struct AppSettingsTests { - @Test func defaults() { +final class AppSettingsTests: XCTestCase { + func testDefaults() { let s = AppSettings.default - #expect(s.argyllBinaryDir == nil) - #expect(s.defaultInstrument == nil) - #expect(s.logLevel == nil) - #expect(s.deltaEGoodMax == 2.0) - #expect(s.deltaEWarningMax == 5.0) - #expect(s.customPresets.isEmpty) - #expect(!s.enableI1Pro2Leds) - #expect(s.calibrationStaleDays == 30) - #expect(s.defaultInstallLocation == .user) - #expect(s.askBeforeOverwriteProfile) - #expect(!s.openColorPanelAfterInstall) - #expect(s.isValid) + XCTAssertNil(s.argyllBinaryDir) + XCTAssertNil(s.defaultInstrument) + XCTAssertNil(s.logLevel) + XCTAssertEqual(s.deltaEGoodMax, 2.0) + XCTAssertEqual(s.deltaEWarningMax, 5.0) + XCTAssertTrue(s.customPresets.isEmpty) + XCTAssertFalse(s.enableI1Pro2Leds) + XCTAssertEqual(s.calibrationStaleDays, 30) + XCTAssertEqual(s.defaultInstallLocation, .user) + XCTAssertTrue(s.askBeforeOverwriteProfile) + XCTAssertFalse(s.openColorPanelAfterInstall) + XCTAssertTrue(s.isValid) } - @Test func negativeThresholds() { + func testNegativeThresholds() { var s = AppSettings.default s.deltaEGoodMax = -1 - #expect(s.validate() == [AppSettings.errorNegativeDeltaE]) + XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE]) s.deltaEGoodMax = 2.0 s.deltaEWarningMax = -0.5 // -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too - #expect(s.validate() == [ + XCTAssertTrue(s.validate() == [ AppSettings.errorNegativeDeltaE, AppSettings.errorThresholdOrder, ]) } - @Test func goodMustBeStrictlyLessThanWarning() { + func testGoodMustBeStrictlyLessThanWarning() { var s = AppSettings.default s.deltaEGoodMax = 5.0 - #expect(s.validate() == [AppSettings.errorThresholdOrder]) + XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder]) s.deltaEGoodMax = 6.0 - #expect(s.validate() == [AppSettings.errorThresholdOrder]) + XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder]) s.deltaEGoodMax = 4.9 - #expect(s.isValid) + XCTAssertTrue(s.isValid) } - @Test func snakeCaseKeys() throws { + func testSnakeCaseKeys() throws { let s = AppSettings.default let data = try JSONEncoder().encode(s) let json = String(data: data, encoding: .utf8)! - #expect(json.contains("\"delta_e_good_max\"")) - #expect(json.contains("\"default_install_location\"")) - #expect(json.contains("\"enable_i1pro2_leds\"")) + XCTAssertTrue(json.contains("\"delta_e_good_max\"")) + XCTAssertTrue(json.contains("\"default_install_location\"")) + XCTAssertTrue(json.contains("\"enable_i1pro2_leds\"")) } } -@Suite("SettingsStore") -struct SettingsStoreTests { - @Test func roundTrip() throws { +final class SettingsStoreTests: XCTestCase { + func testRoundTrip() throws { let url = tempStoreURL() let store = SettingsStore(fileURL: url) var s = AppSettings.default s.deltaEGoodMax = 1.5 s.defaultInstrument = "p3" try store.save(s) - #expect(store.load() == s) + XCTAssertEqual(store.load(), s) } - @Test func corruptJsonFallsBackToDefaults() throws { + func testCorruptJsonFallsBackToDefaults() throws { let url = tempStoreURL() try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) try "{ not json".write(to: url, atomically: true, encoding: .utf8) - #expect(SettingsStore(fileURL: url).load() == .default) + XCTAssertEqual(SettingsStore(fileURL: url).load(), .default) } - @Test func missingFileReturnsDefaults() { - #expect(SettingsStore(fileURL: tempStoreURL()).load() == .default) + func testMissingFileReturnsDefaults() { + XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default) } - @Test func invalidSettingsNotPersisted() throws { + func testInvalidSettingsNotPersisted() throws { let url = tempStoreURL() let store = SettingsStore(fileURL: url) var s = AppSettings.default s.deltaEGoodMax = 9.0 // >= warning 5.0 - #expect(throws: SettingsStore.SettingsError.self) { try store.save(s) } - #expect(!FileManager.default.fileExists(atPath: url.path)) + XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) } - @Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws { + func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws { let url = tempStoreURL() let store = SettingsStore(fileURL: url) var valid = AppSettings.default @@ -109,13 +107,13 @@ struct SettingsStoreTests { var invalid = AppSettings.default invalid.deltaEGoodMax = 9.0 - #expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) } - #expect(try Data(contentsOf: url) == originalBytes) - #expect(!fired) - #expect(store.load() == valid) + XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) } + XCTAssertEqual(try Data(contentsOf: url), originalBytes) + XCTAssertFalse(fired) + XCTAssertEqual(store.load(), valid) } - @Test func savePostsNotification() async throws { + func testSavePostsNotification() async throws { let url = tempStoreURL() let store = SettingsStore(fileURL: url) var fired = false @@ -124,12 +122,11 @@ struct SettingsStoreTests { ) { _ in fired = true } defer { NotificationCenter.default.removeObserver(token) } try store.save(.default) - #expect(fired) + XCTAssertTrue(fired) } } -@Suite("LogSink") -struct LogSinkTests { +final class LogSinkTests: XCTestCase { private func tempLog() -> (URL, LogSink) { let url = FileManager.default.temporaryDirectory .appendingPathComponent("iccery-log-\(UUID().uuidString)") @@ -137,26 +134,26 @@ struct LogSinkTests { return (url, LogSink(fileURL: url)) } - @Test func writesFormattedLines() { + func testWritesFormattedLines() { let (url, sink) = tempLog() sink.setLevel(.debug) sink.write(level: .info, category: "test", message: "hello") let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" - #expect(content.contains("[INFO] test: hello")) + XCTAssertTrue(content.contains("[INFO] test: hello")) } - @Test func levelFilteringIsLive() { + func testLevelFilteringIsLive() { let (url, sink) = tempLog() sink.setLevel(.error) sink.write(level: .info, category: "t", message: "hidden") sink.setLevel(.info) // runtime change, no restart (#158) sink.write(level: .info, category: "t", message: "shown") let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" - #expect(!content.contains("hidden")) - #expect(content.contains("shown")) + XCTAssertFalse(content.contains("hidden")) + XCTAssertTrue(content.contains("shown")) } - @Test func rotatesAt5MiBKeeping5Segments() throws { + func testRotatesAt5MiBKeeping5Segments() throws { let (url, sink) = tempLog() sink.setLevel(.trace) // Pre-fill the active log just under the cap, then cross it. @@ -167,20 +164,20 @@ struct LogSinkTests { try big.write(to: url, atomically: true, encoding: .utf8) sink.write(level: .info, category: "t", message: "trigger rotation") - #expect(FileManager.default.fileExists( + XCTAssertTrue(FileManager.default.fileExists( atPath: url.appendingPathExtension("1").path )) // Active log is small again. let size = try FileManager.default.attributesOfItem( atPath: url.path )[.size] as? UInt64 - #expect((size ?? 0) < 1024) + XCTAssertTrue((size ?? 0) < 1024) } - @Test func tailExcerptCaps() throws { + func testTailExcerptCaps() throws { let (url, sink) = tempLog() sink.setLevel(.debug) sink.write(level: .info, category: "t", message: "line") - #expect(sink.tailExcerpt(maxBytes: 8).count <= 8) + XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8) } } diff --git a/Tests/ICCeryCoreTests/TargenTests.swift b/Tests/ICCeryCoreTests/TargenTests.swift index c11a0a1..ce2f2d8 100644 --- a/Tests/ICCeryCoreTests/TargenTests.swift +++ b/Tests/ICCeryCoreTests/TargenTests.swift @@ -1,4 +1,3 @@ -import Testing import XCTest import Foundation @testable import ICCeryCore @@ -220,11 +219,9 @@ final class TargenArgsTests: XCTestCase { } } -@Suite("ArgyllRunner Targen") -struct ArgyllRunnerTargenTests { +final class ArgyllRunnerTargenTests: XCTestCase { - @Test("Successful targen execution creates .ti1 and returns URL") - func successfulTargenExecution() async throws { + func testSuccessfulTargenExecution() async throws { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -270,14 +267,13 @@ struct ArgyllRunnerTargenTests { box.append(batch) } logLines = box.lines - #expect(logLines.contains("Generating patches...")) + XCTAssertTrue(logLines.contains("Generating patches...")) - #expect(FileManager.default.fileExists(atPath: ti1URL.path)) - #expect(ti1URL.lastPathComponent == "mock_test.ti1") + XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path)) + XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1") } - @Test("Failed targen execution throws toolFailed") - func failedTargenExecution() async throws { + func testFailedTargenExecution() async throws { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -304,14 +300,15 @@ struct ArgyllRunnerTargenTests { workingDirectory: tempDir ) - await #expect(throws: ArgyllRunnerError.toolFailed( - tool: "targen", code: 1, logs: ["Error: something went wrong"])) { + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { try await runner.runTargen(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .toolFailed( + tool: "targen", code: 1, logs: ["Error: something went wrong"])) } } - @Test("Targen exit 0 without .ti1 throws missingArtefact") - func missingArtefactThrows() async throws { + func testMissingArtefactThrows() async throws { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -338,9 +335,11 @@ struct ArgyllRunnerTargenTests { workingDirectory: tempDir ) - await #expect(throws: ArgyllRunnerError.missingArtefact( - tempDir.appendingPathComponent("no_file.ti1").path)) { + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { try await runner.runTargen(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .missingArtefact( + tempDir.appendingPathComponent("no_file.ti1").path)) } } } diff --git a/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift b/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift index beac3f3..84e13e9 100644 --- a/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift +++ b/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift @@ -1,17 +1,15 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore @testable import ICCery /// Dataset-import error contracts through the /// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O /// failures must surface identically as a single `.error` Notice. -@Suite("TargetWorkflowViewModel dataset import") @MainActor -struct TargetWorkflowViewModelTests { +final class TargetWorkflowViewModelTests: XCTestCase { - @Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'") - func malformedDatasetNotice() throws { + func testMalformedDatasetNotice() throws { let env = try TestAppEnvironment.make() defer { env.cleanup() } let vm = TargetWorkflowViewModel(environment: env.environment) @@ -21,13 +19,12 @@ struct TargetWorkflowViewModelTests { vm.importMeasurementDataset(from: bad) - let notice = try #require(vm.wizard.notice) - #expect(notice.kind == .error) - #expect(notice.text.hasPrefix("Import failed:")) + let notice = try XCTUnwrap(vm.wizard.notice) + XCTAssertEqual(notice.kind, .error) + XCTAssertTrue(notice.text.hasPrefix("Import failed:")) } - @Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'") - func missingDatasetNotice() throws { + func testMissingDatasetNotice() throws { let env = try TestAppEnvironment.make() defer { env.cleanup() } let vm = TargetWorkflowViewModel(environment: env.environment) @@ -35,8 +32,8 @@ struct TargetWorkflowViewModelTests { let missing = env.root.appendingPathComponent("does-not-exist.ti3") vm.importMeasurementDataset(from: missing) - let notice = try #require(vm.wizard.notice) - #expect(notice.kind == .error) - #expect(notice.text.hasPrefix("Import failed:")) + let notice = try XCTUnwrap(vm.wizard.notice) + XCTAssertEqual(notice.kind, .error) + XCTAssertTrue(notice.text.hasPrefix("Import failed:")) } } diff --git a/Tests/ICCeryCoreTests/VerificationHistoryStoreTests.swift b/Tests/ICCeryCoreTests/VerificationHistoryStoreTests.swift index 313b297..7f2ae46 100644 --- a/Tests/ICCeryCoreTests/VerificationHistoryStoreTests.swift +++ b/Tests/ICCeryCoreTests/VerificationHistoryStoreTests.swift @@ -1,12 +1,10 @@ import Foundation -import Testing +import XCTest @testable import ICCeryCore -@Suite("VerificationHistoryStore") -struct VerificationHistoryStoreTests { +final class VerificationHistoryStoreTests: XCTestCase { - @Test("Append and cap") - func appendAndCap() async throws { + func testAppendAndCap() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -29,12 +27,11 @@ struct VerificationHistoryStoreTests { } let all = await store.all() - #expect(all.count == 3) - #expect(all.first?.avgDE == 2.0) + XCTAssertEqual(all.count, 3) + XCTAssertEqual(all.first?.avgDE, 2.0) } - @Test("Parse failure preserves file") - func parseFailurePreservesFile() async { + func testParseFailurePreservesFile() async { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -45,14 +42,13 @@ struct VerificationHistoryStoreTests { let store = VerificationHistoryStore(url: url) do { _ = try await store.load() - Issue.record("load() should throw on invalid JSON") + XCTFail("load() should throw on invalid JSON") } catch { - #expect(fm.fileExists(atPath: url.path)) + XCTAssertTrue(fm.fileExists(atPath: url.path)) } } - @Test("Append loads existing records first") - func appendLoadsExisting() async throws { + func testAppendLoadsExisting() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -89,13 +85,12 @@ struct VerificationHistoryStoreTests { _ = try await store2.append(new) let all = await store2.all() - #expect(all.count == 2) - #expect(all.contains { $0.id == "vr-existing" }) - #expect(all.contains { $0.id == "vr-new" }) + XCTAssertEqual(all.count, 2) + XCTAssertTrue(all.contains { $0.id == "vr-existing" }) + XCTAssertTrue(all.contains { $0.id == "vr-new" }) } - @Test("Append does not overwrite an unparseable file") - func appendPreservesUnparseableFile() async { + func testAppendPreservesUnparseableFile() async { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -119,20 +114,19 @@ struct VerificationHistoryStoreTests { do { _ = try await store.append(record) - Issue.record("append() should propagate the load error") + XCTFail("append() should propagate the load error") } catch { - #expect(fm.fileExists(atPath: url.path)) + XCTAssertTrue(fm.fileExists(atPath: url.path)) if let data = try? Data(contentsOf: url), let contents = String(data: data, encoding: .utf8) { - #expect(contents == badJSON) + XCTAssertEqual(contents, badJSON) } else { - Issue.record("Could not read preserved file") + XCTFail("Could not read preserved file") } } } - @Test("Clear does not overwrite an unparseable file") - func clearPreservesUnparseableFile() async { + func testClearPreservesUnparseableFile() async { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -144,15 +138,14 @@ struct VerificationHistoryStoreTests { let store = VerificationHistoryStore(url: url) do { try await store.clear() - Issue.record("clear() should propagate the load error") + XCTFail("clear() should propagate the load error") } catch { let contents = try? String(contentsOf: url, encoding: .utf8) - #expect(contents == badJSON) + XCTAssertEqual(contents, badJSON) } } - @Test("ISO-8601 timestamps round-trip through a fresh store") - func iso8601RoundTrip() async throws { + func testIso8601RoundTrip() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -174,16 +167,15 @@ struct VerificationHistoryStoreTests { _ = try await store1.append(record) let text = try String(contentsOf: url, encoding: .utf8) - #expect(text.contains(ISO8601DateFormatter().string(from: timestamp))) + XCTAssertTrue(text.contains(ISO8601DateFormatter().string(from: timestamp))) let store2 = VerificationHistoryStore(url: url) let loaded = try await store2.load() - #expect(loaded.count == 1) - #expect(loaded.first?.timestamp == timestamp) + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.timestamp, timestamp) } - @Test("CSV export quoting") - func csvQuoting() async throws { + func testCsvQuoting() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -204,7 +196,7 @@ struct VerificationHistoryStoreTests { _ = try await store.append(record) let csv = await store.exportCSV() - #expect(csv.contains("\"a,b\"")) - #expect(csv.contains("\"\"quoted\"\"")) + XCTAssertTrue(csv.contains("\"a,b\"")) + XCTAssertTrue(csv.contains("\"\"quoted\"\"")) } } diff --git a/Tests/ICCeryCoreTests/WizardCalibrationSessionTests.swift b/Tests/ICCeryCoreTests/WizardCalibrationSessionTests.swift index 42be31c..392fe4c 100644 --- a/Tests/ICCeryCoreTests/WizardCalibrationSessionTests.swift +++ b/Tests/ICCeryCoreTests/WizardCalibrationSessionTests.swift @@ -1,13 +1,12 @@ -import Testing import Foundation +import XCTest @testable import ICCeryCore @testable import ICCery /// Issue #29 — `CAL_` basename must be restored on relaunch and on any /// attempt to navigate to a non-calibration stage that would use it. -@Suite("WizardCalibrationSession") @MainActor -struct WizardCalibrationSessionTests { +final class WizardCalibrationSessionTests: XCTestCase { private func tempURL() -> URL { FileManager.default.temporaryDirectory @@ -24,8 +23,7 @@ struct WizardCalibrationSessionTests { return url } - @Test("Persist and restore calibrationOriginalBasename across a relaunch") - func relaunchRestoresOriginal() throws { + func testRelaunchRestoresOriginal() throws { let url = tempURL() let store = WizardStateStore(fileURL: url) var saved = WizardState( @@ -39,14 +37,13 @@ struct WizardCalibrationSessionTests { let model = WizardViewModel(stateStore: store) - #expect(model.basename == "DemoTarget") - #expect(model.calibrationOriginalBasename == "") - #expect(model.sessionMode == .profile) - #expect(model.stage == .generate) + XCTAssertEqual(model.basename, "DemoTarget") + XCTAssertEqual(model.calibrationOriginalBasename, "") + XCTAssertEqual(model.sessionMode, .profile) + XCTAssertEqual(model.stage, .generate) } - @Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original") - func goToBuildProfileRefusesAndRestores() throws { + func testGoToBuildProfileRefusesAndRestores() throws { let dir = try tempDir() let url = tempURL() let store = WizardStateStore(fileURL: url) @@ -60,14 +57,13 @@ struct WizardCalibrationSessionTests { model.go(to: .buildProfile) - #expect(model.basename == "DemoTarget") - #expect(model.calibrationOriginalBasename == "") - #expect(model.sessionMode == .profile) - #expect(model.stage == .calibrate) + XCTAssertEqual(model.basename, "DemoTarget") + XCTAssertEqual(model.calibrationOriginalBasename, "") + XCTAssertEqual(model.sessionMode, .profile) + XCTAssertEqual(model.stage, .calibrate) } - @Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration") - func goToLayoutStaysCal() throws { + func testGoToLayoutStaysCal() throws { let dir = try tempDir() let url = tempURL() let store = WizardStateStore(fileURL: url) @@ -81,9 +77,9 @@ struct WizardCalibrationSessionTests { model.go(to: .layOutPrint) - #expect(model.basename == "CAL_DemoTarget") - #expect(model.calibrationOriginalBasename == "DemoTarget") - #expect(model.sessionMode == .calibration) - #expect(model.stage == .layOutPrint) + XCTAssertEqual(model.basename, "CAL_DemoTarget") + XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget") + XCTAssertEqual(model.sessionMode, .calibration) + XCTAssertEqual(model.stage, .layOutPrint) } } diff --git a/Tests/ICCeryCoreTests/WizardGatingTests.swift b/Tests/ICCeryCoreTests/WizardGatingTests.swift index 2f8a6b9..3fbde91 100644 --- a/Tests/ICCeryCoreTests/WizardGatingTests.swift +++ b/Tests/ICCeryCoreTests/WizardGatingTests.swift @@ -1,5 +1,5 @@ -import Testing import Foundation +import XCTest @testable import ICCeryCore private func artefacts( @@ -16,77 +16,75 @@ private func artefacts( return a } -@Suite("WizardGating matrix") -struct WizardGatingTests { +final class WizardGatingTests: XCTestCase { - @Test func emptyProjectOnlyStage1() { + func testEmptyProjectOnlyStage1() { let a = artefacts() - #expect(WizardGating.isUnlocked(.generate, artefacts: a)) - #expect(WizardGating.isUnlocked(.calibrate, artefacts: a)) + XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a)) + XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a)) for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] { - #expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked") + XCTAssertFalse(WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked") } } - @Test func ti1UnlocksStage2Only() { + func testTi1UnlocksStage2Only() { let a = artefacts(ti1: true) - #expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) - #expect(!WizardGating.isUnlocked(.measure, artefacts: a)) - #expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) - #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a)) + XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) + XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a)) + XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a)) + XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a)) } - @Test func stage3NeedsTi1AndTi2() { - #expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) - #expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true))) + func testStage3NeedsTi1AndTi2() { + XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) + XCTAssertTrue(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true))) } - @Test func stage4NeedsTi3NotTi2() { + func testStage4NeedsTi3NotTi2() { // #109/#110: .ti2 alone must never unlock Stage 4. let a = artefacts(ti1: true, ti2: true) - #expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) - #expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) + XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a)) + XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) } - @Test func stage5NeedsTi3AndProfile() { - #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) - #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) - #expect(WizardGating.isUnlocked( + func testStage5NeedsTi3AndProfile() { + XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) + XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) + XCTAssertTrue(WizardGating.isUnlocked( .verifyInstall, artefacts: artefacts(ti3: true, profile: true) )) } - @Test func forwardGatedBackwardFree() { + func testForwardGatedBackwardFree() { let a = artefacts() - #expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a)) + XCTAssertFalse(WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a)) // Backward always allowed even when artefacts vanished. - #expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a)) + XCTAssertTrue(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a)) // Same stage is a no-op. - #expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a)) + XCTAssertTrue(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a)) // Stage 0 is a side-trip, never gated. - #expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a)) + XCTAssertTrue(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a)) } - @Test func deepestUnlocked() { - #expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate) - #expect(WizardGating.deepestUnlocked( + func testDeepestUnlocked() { + XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate) + XCTAssertEqual(WizardGating.deepestUnlocked( artefacts: artefacts(ti1: true, ti2: true) - ) == .measure) - #expect(WizardGating.deepestUnlocked( + ), .measure) + XCTAssertEqual(WizardGating.deepestUnlocked( artefacts: artefacts(ti3: true, profile: true) - ) == .verifyInstall) + ), .verifyInstall) } } -@Suite("WizardStateStore") -struct WizardStateStoreTests { +final class WizardStateStoreTests: XCTestCase { private func tempURL() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent("iccery-wiz-\(UUID().uuidString)") .appendingPathComponent("wizard_state.json") } - @Test func roundTrip() throws { + func testRoundTrip() throws { let url = tempURL() let store = WizardStateStore(fileURL: url) var s = WizardState() @@ -97,43 +95,43 @@ struct WizardStateStoreTests { s.profileBasename = "imported" s.calibrationOriginalBasename = "pre-cal" try store.save(s) - #expect(store.load() == s) + XCTAssertEqual(store.load(), s) } - @Test func missingFileDefaults() { + func testMissingFileDefaults() { let s = WizardStateStore(fileURL: tempURL()).load() - #expect(s == .default) - #expect(s.stage == .generate) - #expect(s.sessionMode == .profile) + XCTAssertEqual(s, .default) + XCTAssertEqual(s.stage, .generate) + XCTAssertEqual(s.sessionMode, .profile) } - @Test func corruptStageFallsBackToGenerate() throws { + func testCorruptStageFallsBackToGenerate() throws { let url = tempURL() try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"# .write(to: url, atomically: true, encoding: .utf8) - #expect(WizardStateStore(fileURL: url).load().stage == .generate) + XCTAssertEqual(WizardStateStore(fileURL: url).load().stage, .generate) } - @Test func corruptJsonReturnsDefaultAndKeepsBytes() throws { + func testCorruptJsonReturnsDefaultAndKeepsBytes() throws { let url = tempURL() try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) try "not json".write(to: url, atomically: true, encoding: .utf8) - #expect(WizardStateStore(fileURL: url).load() == .default) + XCTAssertEqual(WizardStateStore(fileURL: url).load(), .default) let kept = try String(contentsOf: url, encoding: .utf8) - #expect(kept == "not json") + XCTAssertEqual(kept, "not json") } - @Test func sessionModeCalibrationRoundTrips() throws { + func testSessionModeCalibrationRoundTrips() throws { var s = WizardState(sessionMode: .calibration) let data = try JSONEncoder().encode(s) let decoded = try JSONDecoder().decode(WizardState.self, from: data) - #expect(decoded.sessionMode == .calibration) + XCTAssertEqual(decoded.sessionMode, .calibration) s.sessionMode = .profile - #expect(s.sessionMode == .profile) + XCTAssertEqual(s.sessionMode, .profile) } } -- 2.39.5 From 32d2184d2e47c06653c390d14e17af882d9fffbd Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 11 Sep 2026 21:51:18 +0100 Subject: [PATCH 04/25] feat(m9): migrate state to Combine and demote SwiftUI views for macOS 12 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Sources/ICCery/CalibrationView.swift | 7 +- Sources/ICCery/CalibrationViewModel.swift | 25 ++-- Sources/ICCery/GamutView.swift | 37 ++++-- Sources/ICCery/GamutViewModel.swift | 13 +- Sources/ICCery/ICCeryApp.swift | 36 ++++-- .../ICCery/MeasurementWorkflowViewModel.swift | 45 ++++--- Sources/ICCery/PresetDialogs.swift | 4 +- .../ICCery/Print/PrintSessionViewModel.swift | 23 ++-- Sources/ICCery/ProfileWorkflowViewModel.swift | 61 +++++---- Sources/ICCery/RootView.swift | 16 ++- Sources/ICCery/SettingsView.swift | 3 +- Sources/ICCery/SettingsViewModel.swift | 10 +- Sources/ICCery/SidebarView.swift | 20 ++- Sources/ICCery/Stage1View.swift | 2 +- Sources/ICCery/Stage2View.swift | 42 ++++++- Sources/ICCery/Stage3View.swift | 10 +- Sources/ICCery/Stage4View.swift | 10 +- Sources/ICCery/Stage5View.swift | 10 +- Sources/ICCery/TargetWorkflowViewModel.swift | 117 +++++++++--------- Sources/ICCery/WizardViewModel.swift | 27 ++-- .../CupsOptionsFilterTests.swift | 10 +- 21 files changed, 319 insertions(+), 209 deletions(-) diff --git a/Sources/ICCery/CalibrationView.swift b/Sources/ICCery/CalibrationView.swift index b4eb94c..f0178f4 100644 --- a/Sources/ICCery/CalibrationView.swift +++ b/Sources/ICCery/CalibrationView.swift @@ -3,8 +3,8 @@ import ICCeryCore /// Stage 0 calibration dashboard (issue #29, docs/07). struct CalibrationView: View { - @Bindable var model: CalibrationViewModel - @Bindable var wizard: WizardViewModel + @ObservedObject var model: CalibrationViewModel + @ObservedObject var wizard: WizardViewModel var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -73,7 +73,7 @@ struct CalibrationView: View { if let url = model.computedCalURL { Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) - .onChange(of: model.applyToProfile) { model.updateApplyToProfile() } + .onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() } .accessibilityIdentifier("calApplyToggle") Text("Loaded: \(url.lastPathComponent)") @@ -96,7 +96,6 @@ struct CalibrationView: View { } } } - .formStyle(.grouped) HStack { Spacer() diff --git a/Sources/ICCery/CalibrationViewModel.swift b/Sources/ICCery/CalibrationViewModel.swift index d19db7f..5736609 100644 --- a/Sources/ICCery/CalibrationViewModel.swift +++ b/Sources/ICCery/CalibrationViewModel.swift @@ -1,13 +1,12 @@ +import Combine import Foundation -import Observation import SwiftUI import ICCeryCore /// Stage 0 calibration workflow: generate wedge, print, measure, and /// compute `.cal` curves. @MainActor -@Observable -final class CalibrationViewModel { +final class CalibrationViewModel: ObservableObject { let workflow: TargetWorkflowViewModel let profile: ProfileWorkflowViewModel @@ -15,16 +14,16 @@ final class CalibrationViewModel { // MARK: - Form state - var colourSpace: ColourSpace = .cmyk - var steps: Int = 21 - var whitePatches: Int = 4 - var includeNeutralEmphasis: Bool = false - var inkLimit: String = "320" - var applyToProfile: Bool = false - var computedCalURL: URL? - var calibrationLog: [String] = [] - var isGenerating = false - var isComputing = false + @Published var colourSpace: ColourSpace = .cmyk + @Published var steps: Int = 21 + @Published var whitePatches: Int = 4 + @Published var includeNeutralEmphasis: Bool = false + @Published var inkLimit: String = "320" + @Published var applyToProfile: Bool = false + @Published var computedCalURL: URL? + @Published var calibrationLog: [String] = [] + @Published var isGenerating = false + @Published var isComputing = false init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { self.workflow = workflow diff --git a/Sources/ICCery/GamutView.swift b/Sources/ICCery/GamutView.swift index 96cbe43..aa3debb 100644 --- a/Sources/ICCery/GamutView.swift +++ b/Sources/ICCery/GamutView.swift @@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder { /// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* /// (blue-yellow) is depth. struct GamutView: View { - @State private var viewModel: GamutViewModel + @StateObject private var viewModel: GamutViewModel @State private var pause: () -> Void = {} @FocusState private var isFocused: Bool init(profileGamURL: URL? = nil) { - _viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) + _viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) } var body: some View { @@ -87,11 +87,6 @@ struct GamutView: View { ) .focusable() .focused($isFocused) - .focusEffectDisabled() - .onKeyPress(.init("R"), action: { - viewModel.resetCamera() - return .handled - }) .onAppear { isFocused = true } VStack { @@ -148,6 +143,7 @@ private struct GamutSceneView: NSViewRepresentable { context.coordinator.scnView = scnView context.coordinator.scene = scene context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) + context.coordinator.installKeyMonitor() return scnView } @@ -168,6 +164,7 @@ private struct GamutSceneView: NSViewRepresentable { } static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { + coordinator.removeKeyMonitor() nsView.isPlaying = false } @@ -175,6 +172,7 @@ private struct GamutSceneView: NSViewRepresentable { final class Coordinator: NSObject { weak var scnView: SCNView? weak var scene: SCNScene? + private var keyMonitor: Any? private let profileNode = SCNNode() private let referenceGroup = SCNNode() @@ -431,6 +429,31 @@ private struct GamutSceneView: NSViewRepresentable { scnView?.isPlaying = false } + /// Local key-down monitor for the R camera-reset shortcut (the + /// SwiftUI key-press modifier is unavailable on macOS 12). Only + /// events aimed at this view's window are handled; everything + /// else passes through untouched. + func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + guard let self, + let scnView = self.scnView, + event.window === scnView.window, + event.charactersIgnoringModifiers?.uppercased() == "R" + else { return event } + self.resetCamera() + return nil + } + } + + func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + } + func resetCamera() { guard let scnView else { return } diff --git a/Sources/ICCery/GamutViewModel.swift b/Sources/ICCery/GamutViewModel.swift index 27dda7f..6e99565 100644 --- a/Sources/ICCery/GamutViewModel.swift +++ b/Sources/ICCery/GamutViewModel.swift @@ -1,26 +1,25 @@ +import Combine import Foundation import ICCeryCore -import Observation /// View model for the native SceneKit gamut viewer. /// /// Loads the bundled `sRGB.gam` reference immediately and, optionally, a /// printer/profile `.gam` from the current working directory. @MainActor -@Observable -final class GamutViewModel { +final class GamutViewModel: ObservableObject { /// Parsed reference sRGB gamut mesh. - var sRGBMesh: GamutMesh? + @Published var sRGBMesh: GamutMesh? /// Parsed printer/profile gamut mesh. - var profileMesh: GamutMesh? + @Published var profileMesh: GamutMesh? /// User-facing status line. - var status = "Loading gamut…" + @Published var status = "Loading gamut…" /// Closure injected into the SceneKit view to request a camera reset. - var resetCamera: () -> Void = {} + @Published var resetCamera: () -> Void = {} private let profileGamURL: URL? diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift index d5dd14c..d0bec78 100644 --- a/Sources/ICCery/ICCeryApp.swift +++ b/Sources/ICCery/ICCeryApp.swift @@ -5,11 +5,11 @@ import SwiftUI @main struct ICCeryApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @State private var workflow: TargetWorkflowViewModel + @StateObject private var workflow: TargetWorkflowViewModel init() { let environment = AppEnvironment.live() - _workflow = State(initialValue: TargetWorkflowViewModel(environment: environment)) + _workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment)) try? AppPaths.ensureDirectories() // Log level is runtime state — apply persisted settings at // startup (#158); the Settings sheet re-applies on save. @@ -17,15 +17,17 @@ struct ICCeryApp: App { } var body: some Scene { - // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700). - Window("ICCery", id: "main") { + // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700); + // metrics are applied by AppDelegate once the window exists. + WindowGroup("ICCery") { RootView(workflow: workflow) .frame(minWidth: 1100, minHeight: 700) .preferredColorScheme(.dark) } - .defaultSize(width: 1280, height: 800) - .windowResizability(.contentMinSize) - .defaultPosition(.center) + .commands { + // Single-window app: no File > New window. + CommandGroup(replacing: .newItem) {} + } } } @@ -37,16 +39,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var terminationRequested = false func applicationDidFinishLaunching(_ notification: Notification) { - // SwiftUI `Window` scenes launched by XCTest stay - // `.runningBackground` unless the app takes regular activation - // and orders the window front (CI run 29804). + // SwiftUI scenes launched by XCTest stay `.runningBackground` + // unless the app takes regular activation and orders the window + // front (CI run 29804). NSApp.setActivationPolicy(.regular) for window in NSApp.windows { + configureMainWindow(window) window.makeKeyAndOrderFront(nil) } NSApp.activate(ignoringOtherApps: true) } + /// docs/21 §Shell: 1280×800 content, min 1100×700, centred. + private func configureMainWindow(_ window: NSWindow) { + window.setContentSize(NSSize(width: 1280, height: 800)) + window.contentMinSize = NSSize(width: 1100, height: 700) + window.center() + } + + /// Dock-click reopen: let the WindowGroup re-show or recreate the + /// main window when none are visible. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + true + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } diff --git a/Sources/ICCery/MeasurementWorkflowViewModel.swift b/Sources/ICCery/MeasurementWorkflowViewModel.swift index 6ec4bba..59656d5 100644 --- a/Sources/ICCery/MeasurementWorkflowViewModel.swift +++ b/Sources/ICCery/MeasurementWorkflowViewModel.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import SwiftUI import ICCeryCore @@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable { /// Stage 3 workflow state and interaction (issues #18–#22). @MainActor -@Observable -final class MeasurementWorkflowViewModel { +final class MeasurementWorkflowViewModel: ObservableObject { // MARK: - Authorities @@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel { // MARK: - Settings-driven thresholds - private(set) var goodMax: Double = 2.0 - private(set) var warningMax: Double = 5.0 - private(set) var enableLEDs: Bool = false + @Published private(set) var goodMax: Double = 2.0 + @Published private(set) var warningMax: Double = 5.0 + @Published private(set) var enableLEDs: Bool = false // MARK: - Instrument detection - var instruments: [InstrumentDevice] = [] - var selectedInstrument: InstrumentSelection = .auto - var isDetecting = false - var detectionError: String? + @Published var instruments: [InstrumentDevice] = [] + @Published var selectedInstrument: InstrumentSelection = .auto + @Published var isDetecting = false + @Published var detectionError: String? // MARK: - Chartread session - var isChartreadRunning = false - var chartreadState: ChartreadState = .idle - var currentPrompt: String? - var requestedWarningKey: String? - var chartreadLog: [String] = [] - var rows: [ChartreadRow] = [] - var swatchRows: [SwatchRow] = [] - var showRemoveSheetNotice = false + @Published var isChartreadRunning = false + @Published var chartreadState: ChartreadState = .idle + @Published var currentPrompt: String? + @Published var requestedWarningKey: String? + @Published var chartreadLog: [String] = [] + @Published var rows: [ChartreadRow] = [] + @Published var swatchRows: [SwatchRow] = [] + @Published var showRemoveSheetNotice = false /// Stage-local chartread error notice (`#chartreadLastError`, #80). - var chartreadNotice: Notice? + @Published var chartreadNotice: Notice? private var chartreadTask: Task? // MARK: - Averaging - var passSnapshots: [URL] = [] - var isFinishing = false - var finishNotice: Notice? - var resumedFromTi2 = false + @Published var passSnapshots: [URL] = [] + @Published var isFinishing = false + @Published var finishNotice: Notice? + @Published var resumedFromTi2 = false init(wizard: WizardViewModel, environment: AppEnvironment) { self.wizard = wizard diff --git a/Sources/ICCery/PresetDialogs.swift b/Sources/ICCery/PresetDialogs.swift index e9781a9..40a072c 100644 --- a/Sources/ICCery/PresetDialogs.swift +++ b/Sources/ICCery/PresetDialogs.swift @@ -4,7 +4,7 @@ import ICCeryCore /// `#savePresetDialog` — save the live Stage 1/2 form as a custom /// preset (issue #11). Names/descriptions render via `Text` only (#114). struct SavePresetDialog: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -35,7 +35,7 @@ struct SavePresetDialog: View { /// `#managePresetsDialog` — list, delete (custom only), import, export. struct ManagePresetsDialog: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel var body: some View { VStack(alignment: .leading, spacing: 12) { diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index b35cf99..f1f59f7 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -1,23 +1,22 @@ +import Combine import Foundation -import Observation import ICCeryCore /// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85). @MainActor -@Observable -final class PrintSessionViewModel { +final class PrintSessionViewModel: ObservableObject { let wizard: WizardViewModel let environment: AppEnvironment - var printers: [Printer] = [] - var selectedPrinter = "" - var printerCaps = PrinterCapabilities() - var selectedTray: Int? - var selectedMediaType: String? - var printOrientation = "portrait" - var capturedCupsOptions: [String: String] = [:] - var printNotice: Notice? - var isPrinting = false + @Published var printers: [Printer] = [] + @Published var selectedPrinter = "" + @Published var printerCaps = PrinterCapabilities() + @Published var selectedTray: Int? + @Published var selectedMediaType: String? + @Published var printOrientation = "portrait" + @Published var capturedCupsOptions: [String: String] = [:] + @Published var printNotice: Notice? + @Published var isPrinting = false private var printTask: Task? init(wizard: WizardViewModel, environment: AppEnvironment) { diff --git a/Sources/ICCery/ProfileWorkflowViewModel.swift b/Sources/ICCery/ProfileWorkflowViewModel.swift index 8426e61..5903606 100644 --- a/Sources/ICCery/ProfileWorkflowViewModel.swift +++ b/Sources/ICCery/ProfileWorkflowViewModel.swift @@ -1,12 +1,11 @@ +import Combine import Foundation -import Observation import SwiftUI import ICCeryCore /// Stage 4/5 workflow: build a profile, verify it, track drift, and install. @MainActor -@Observable -final class ProfileWorkflowViewModel { +final class ProfileWorkflowViewModel: ObservableObject { let wizard: WizardViewModel let environment: AppEnvironment @@ -14,50 +13,50 @@ final class ProfileWorkflowViewModel { // MARK: - Stage 4 form - var algorithm: String = "l" // l | x | X | m - var quality: String = "m" // l | m | h | u - var intent: String = "" // usually empty at Stage 4 - var fwaSelection: ColprofFwaSelection = .none - var fwaCustomPath: String = "" - var illuminant: String = "" - var observer: String = "" - var inputViewingCond: String = "" - var outputViewingCond: String = "" - var profileDescription: String = "" - var copyright: String = "" + @Published var algorithm: String = "l" // l | x | X | m + @Published var quality: String = "m" // l | m | h | u + @Published var intent: String = "" // usually empty at Stage 4 + @Published var fwaSelection: ColprofFwaSelection = .none + @Published var fwaCustomPath: String = "" + @Published var illuminant: String = "" + @Published var observer: String = "" + @Published var inputViewingCond: String = "" + @Published var outputViewingCond: String = "" + @Published var profileDescription: String = "" + @Published var copyright: String = "" // MARK: - Run state - var isColprofRunning = false - var colprofLog: [String] = [] - var colprofProgress: String? - var createdProfileURL: URL? + @Published var isColprofRunning = false + @Published var colprofLog: [String] = [] + @Published var colprofProgress: String? + @Published var createdProfileURL: URL? /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). - var createdGamutURL: URL? + @Published var createdGamutURL: URL? // MARK: - Stage 4/5 calibration (issue #24) - var applyCalibration = false - var calibrationFile: String = "" + @Published var applyCalibration = false + @Published var calibrationFile: String = "" // MARK: - Stage 5 verification (issue #25) - var profcheckReport: ProfcheckReport? - var profcheckWarning: String? - var isProfcheckRunning = false + @Published var profcheckReport: ProfcheckReport? + @Published var profcheckWarning: String? + @Published var isProfcheckRunning = false // MARK: - History / drift (issue #26) - var verificationHistory: [VerificationRecord] = [] - var driftPrinterFilter: String? = nil - var driftAlert: String? - var isHistoryStoreError: String? + @Published var verificationHistory: [VerificationRecord] = [] + @Published var driftPrinterFilter: String? = nil + @Published var driftAlert: String? + @Published var isHistoryStoreError: String? // MARK: - Install (issue #27) - var installResult: InstallProfileResult? - var showingInstallCollision = false - var installCollisionMessage: String = "" + @Published var installResult: InstallProfileResult? + @Published var showingInstallCollision = false + @Published var installCollisionMessage: String = "" var pendingInstallOptions: InstallProfileOptions? init(wizard: WizardViewModel, environment: AppEnvironment) { diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index cfedcb1..aa67a55 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -5,12 +5,18 @@ import ICCeryCore /// Root layout: 270 pt sidebar + main stage area with the notification /// banner pinned to the top (docs/21 §Shell). struct RootView: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var model: WizardViewModel @State private var showingSettings = false @State private var showingAbout = false @State private var showingAllHelp = false - private var model: WizardViewModel { workflow.wizard } + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._model = ObservedObject(wrappedValue: workflow.wizard) + } var body: some View { HStack(spacing: 0) { @@ -64,11 +70,11 @@ struct RootView: View { } /// Content for the active wizard stage. Isolated into its own view so that -/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's +/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's /// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations. private struct WizardStageContent: View { - @Bindable var model: WizardViewModel - var workflow: TargetWorkflowViewModel + @ObservedObject var model: WizardViewModel + @ObservedObject var workflow: TargetWorkflowViewModel var body: some View { switch model.stage { diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift index 085e7ff..f55a0b7 100644 --- a/Sources/ICCery/SettingsView.swift +++ b/Sources/ICCery/SettingsView.swift @@ -4,7 +4,7 @@ import ICCeryCore /// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with /// the full v1 field set; ΔE validation shows inline under the fields. struct SettingsView: View { - @State var model = SettingsViewModel() + @StateObject var model = SettingsViewModel() @Environment(\.dismiss) private var dismiss private static let instruments: [(code: String, label: String)] = [ @@ -143,7 +143,6 @@ struct SettingsView: View { } } } - .formStyle(.grouped) Divider() diff --git a/Sources/ICCery/SettingsViewModel.swift b/Sources/ICCery/SettingsViewModel.swift index d231309..ed480fa 100644 --- a/Sources/ICCery/SettingsViewModel.swift +++ b/Sources/ICCery/SettingsViewModel.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import Foundation import ICCeryCore @@ -6,12 +7,11 @@ import ICCeryCore /// validation; the log level is applied live via `LogSink` (#158) and a /// `settingsDidChange` notification fans out to #20. @MainActor -@Observable -final class SettingsViewModel { +final class SettingsViewModel: ObservableObject { - var settings: AppSettings - var validationErrors: [String] = [] - var savedFlash = false + @Published var settings: AppSettings + @Published var validationErrors: [String] = [] + @Published var savedFlash = false private let store: SettingsStore private let sink: LogSink diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index bb7301d..0c4d3c1 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -4,12 +4,28 @@ import ICCeryCore /// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset /// select, Calibrate Printer + status chip, and the 1–5 stepper. struct SidebarView: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var model: WizardViewModel + @ObservedObject private var profile: ProfileWorkflowViewModel var onOpenSettings: () -> Void var onOpenAbout: () -> Void @Binding var showingAllHelp: Bool - private var model: WizardViewModel { workflow.wizard } + init( + workflow: TargetWorkflowViewModel, + onOpenSettings: @escaping () -> Void, + onOpenAbout: @escaping () -> Void, + showingAllHelp: Binding + ) { + self.workflow = workflow + self._model = ObservedObject(wrappedValue: workflow.wizard) + self._profile = ObservedObject(wrappedValue: workflow.profile) + self.onOpenSettings = onOpenSettings + self.onOpenAbout = onOpenAbout + self._showingAllHelp = showingAllHelp + } var body: some View { VStack(alignment: .leading, spacing: 0) { diff --git a/Sources/ICCery/Stage1View.swift b/Sources/ICCery/Stage1View.swift index 349b100..9b40307 100644 --- a/Sources/ICCery/Stage1View.swift +++ b/Sources/ICCery/Stage1View.swift @@ -5,7 +5,7 @@ import ICCeryCore /// docs/08). All documented element ids are wired as accessibility /// identifiers so the UI-test contract stays stable. struct Stage1View: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel var body: some View { ScrollView { diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 9d2c784..e04b162 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -5,7 +5,19 @@ import ICCeryCore /// issues #9/#10, docs/09). Print controls are visible but inert — /// real spooling lands in M3. struct Stage2View: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var printSession: PrintSessionViewModel + @ObservedObject private var wizard: WizardViewModel + + @State private var printGenerationTask: Task? + + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._printSession = ObservedObject(wrappedValue: workflow.print) + self._wizard = ObservedObject(wrappedValue: workflow.wizard) + } var body: some View { ScrollView { @@ -240,7 +252,7 @@ struct Stage2View: View { } .frame(maxWidth: 320) .accessibilityIdentifier("printerSelect") - .onChange(of: workflow.print.selectedPrinter) { _, _ in + .onChange(of: workflow.print.selectedPrinter) { _ in workflow.print.selectedTray = nil workflow.print.selectedMediaType = nil Task { @MainActor in await workflow.print.reloadSelectedCapabilities() } @@ -328,9 +340,18 @@ struct Stage2View: View { .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) .accessibilityElement(children: .contain) .accessibilityIdentifier("rawPrintPanel") - .task(id: workflow.printtargResult?.pages.count) { - // Auto-enumerate once a manifest exists and whenever it - // changes (e.g. resume from .ti2). + .onAppear { schedulePrinterRefresh() } + .onChange(of: workflow.printtargResult?.pages.count) { _ in + schedulePrinterRefresh() + } + } + + /// Auto-enumerates printers once a manifest exists and whenever it + /// changes (e.g. resume from .ti2). The explicit task handle keeps + /// a superseded run from racing the next one. + private func schedulePrinterRefresh() { + printGenerationTask?.cancel() + printGenerationTask = Task { @MainActor in if workflow.print.printers.isEmpty, workflow.printtargResult != nil { workflow.print.refreshPrinters() } @@ -341,7 +362,16 @@ struct Stage2View: View { /// One gallery cell: PNG preview + per-page Print button. private struct GalleryPageView: View { let page: GalleryPage - let workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: `print` is a nested ObservableObject and its + /// `isPrinting`/`selectedPrinter` changes drive this cell's button. + @ObservedObject private var printSession: PrintSessionViewModel + + init(page: GalleryPage, workflow: TargetWorkflowViewModel) { + self.page = page + self.workflow = workflow + self._printSession = ObservedObject(wrappedValue: workflow.print) + } var body: some View { VStack(spacing: 6) { diff --git a/Sources/ICCery/Stage3View.swift b/Sources/ICCery/Stage3View.swift index 4ee7a65..8e1b1d8 100644 --- a/Sources/ICCery/Stage3View.swift +++ b/Sources/ICCery/Stage3View.swift @@ -4,7 +4,15 @@ import ICCeryCore /// Stage 3 — measurement, live swatches, and multi-pass averaging. struct Stage3View: View { - @Bindable var model: MeasurementWorkflowViewModel + @ObservedObject var model: MeasurementWorkflowViewModel + /// `model.basename`/`model.workingDirectory` delegate to `wizard`; + /// observe it directly so header updates propagate. + @ObservedObject private var wizard: WizardViewModel + + init(model: MeasurementWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } var body: some View { VStack(spacing: 0) { diff --git a/Sources/ICCery/Stage4View.swift b/Sources/ICCery/Stage4View.swift index 21333a6..2a400ac 100644 --- a/Sources/ICCery/Stage4View.swift +++ b/Sources/ICCery/Stage4View.swift @@ -3,7 +3,15 @@ import ICCeryCore /// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`. struct Stage4View: View { - @Bindable var model: ProfileWorkflowViewModel + @ObservedObject var model: ProfileWorkflowViewModel + /// Header reads `model.wizard.basename`; observe the nested + /// ObservableObject directly. + @ObservedObject private var wizard: WizardViewModel + + init(model: ProfileWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } var body: some View { VStack(spacing: 0) { diff --git a/Sources/ICCery/Stage5View.swift b/Sources/ICCery/Stage5View.swift index 9ef62c3..c38eaf7 100644 --- a/Sources/ICCery/Stage5View.swift +++ b/Sources/ICCery/Stage5View.swift @@ -3,7 +3,15 @@ import ICCeryCore /// Stage 5 — verify the generated profile, track drift, and install. struct Stage5View: View { - @Bindable var model: ProfileWorkflowViewModel + @ObservedObject var model: ProfileWorkflowViewModel + /// Header/buttons read `model.wizard.*`; observe the nested + /// ObservableObject directly. + @ObservedObject private var wizard: WizardViewModel + + init(model: ProfileWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } var body: some View { VStack(spacing: 0) { diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 3afa2e7..f89d37e 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import ICCeryCore /// Stage 1/2 form state, runner orchestration, resume flow, and preset @@ -10,8 +10,7 @@ import ICCeryCore /// All process work runs through `ArgyllRunner` off `@MainActor`; only /// coalesced log batches and completion hop back. @MainActor -@Observable -final class TargetWorkflowViewModel { +final class TargetWorkflowViewModel: ObservableObject { let wizard: WizardViewModel let environment: AppEnvironment @@ -19,95 +18,95 @@ final class TargetWorkflowViewModel { // MARK: - Stage 1 form (targen) - var colourSpace: ColourSpace = .rgb { + @Published var colourSpace: ColourSpace = .rgb { didSet { guard colourSpace != oldValue else { return } // CMYK black patches default to 0, RGB to 4 (docs/08). blackPatches = colourSpace == .cmyk ? 0 : 4 } } - var patchPreset: PatchCountPreset = .standard800 + @Published var patchPreset: PatchCountPreset = .standard800 /// `#patchCountCustom` — used when `patchPreset == .custom`. - var customPatchCount = 2500 - var whitePatches = 4 - var blackPatches = 4 + @Published var customPatchCount = 2500 + @Published var whitePatches = 4 + @Published var blackPatches = 4 // Advanced — each optional flag is enabled + value, so an untouched // control emits nothing (#advanced fields are opt-in). - var greyStepsEnabled = false - var greySteps = 5 - var singleChannelEnabled = false - var singleChannelSteps = 5 - var neutralStepsEnabled = false - var neutralSteps = 3 - var neutralConcEnabled = false - var neutralConcentration = 0.50 - var preconditioningProfile: String? - var highQuality = false - var adaptationEnabled = false - var adaptation = 0.10 - var algorithm: FullSpreadAlgorithm = .ofps - var inkLimitEnabled = false - var totalInkLimit = 320 - var darkEmphasisEnabled = false - var darkEmphasis = 1.0 - var devicePowerEnabled = false - var devicePower = 1.0 + @Published var greyStepsEnabled = false + @Published var greySteps = 5 + @Published var singleChannelEnabled = false + @Published var singleChannelSteps = 5 + @Published var neutralStepsEnabled = false + @Published var neutralSteps = 3 + @Published var neutralConcEnabled = false + @Published var neutralConcentration = 0.50 + @Published var preconditioningProfile: String? + @Published var highQuality = false + @Published var adaptationEnabled = false + @Published var adaptation = 0.10 + @Published var algorithm: FullSpreadAlgorithm = .ofps + @Published var inkLimitEnabled = false + @Published var totalInkLimit = 320 + @Published var darkEmphasisEnabled = false + @Published var darkEmphasis = 1.0 + @Published var devicePowerEnabled = false + @Published var devicePower = 1.0 /// `#targetBasename` — no placeholder is ever invented (#60). - var targetBasename = "" + @Published var targetBasename = "" /// `#selectedPathDisplay` / resolved cwd. - var targetDirectory: URL? + @Published var targetDirectory: URL? // MARK: - Stage 2 form (printtarg) - var instrument: PrintInstrument = .i1 - var pageSize: PageSize = .a4 - var customPageW = 210.0 - var customPageH = 297.0 - var bitDepth: TiffBitDepth = .eight + @Published var instrument: PrintInstrument = .i1 + @Published var pageSize: PageSize = .a4 + @Published var customPageW = 210.0 + @Published var customPageH = 297.0 + @Published var bitDepth: TiffBitDepth = .eight /// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft /// regression must be visible here). - var tiffDpi = 300 - var layoutOrder: LayoutOrder = .deterministic - var customSeed = 1 - var labelIsCustom = false - var customLabel = "" - var metaPrinter = "" - var metaInkSet = "" - var metaDriverPaper = "" - var metaActualPaper = "" + @Published var tiffDpi = 300 + @Published var layoutOrder: LayoutOrder = .deterministic + @Published var customSeed = 1 + @Published var labelIsCustom = false + @Published var customLabel = "" + @Published var metaPrinter = "" + @Published var metaInkSet = "" + @Published var metaDriverPaper = "" + @Published var metaActualPaper = "" // MARK: - Run state - var targenRunning = false - var targenLog: [String] = [] - var printtargRunning = false - var printtargLog: [String] = [] - var printtargResult: PrinttargResult? + @Published var targenRunning = false + @Published var targenLog: [String] = [] + @Published var printtargRunning = false + @Published var printtargLog: [String] = [] + @Published var printtargResult: PrinttargResult? /// Sticky until the target changes: `.ti2` resume landed us on /// Stage 3 (`#stage3LoadedTargetBanner` data). - var resumedFromTi2 = false + @Published var resumedFromTi2 = false // MARK: - Presets - var presets: [ProfilingPreset] = [] - var selectedPresetID = "none" - var showingSavePreset = false - var showingManagePresets = false - var savePresetName = "" - var savePresetDesc = "" + @Published var presets: [ProfilingPreset] = [] + @Published var selectedPresetID = "none" + @Published var showingSavePreset = false + @Published var showingManagePresets = false + @Published var savePresetName = "" + @Published var savePresetDesc = "" /// Stage 3 measurement workflow, owned at the app level so it persists /// across stage switches and can observe settings changes. - var measurement: MeasurementWorkflowViewModel + @Published var measurement: MeasurementWorkflowViewModel /// Stage 4/5 profile workflow, owned at the app level so it persists /// across stage switches and can observe preset values. - var profile: ProfileWorkflowViewModel + @Published var profile: ProfileWorkflowViewModel /// Stage 0 calibration workflow. - var calibration: CalibrationViewModel! + @Published var calibration: CalibrationViewModel! /// Stage 2 unmanaged print session. - var print: PrintSessionViewModel! + @Published var print: PrintSessionViewModel! init(environment: AppEnvironment = .live()) { self.environment = environment diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift index 55adc72..bbd0aa1 100644 --- a/Sources/ICCery/WizardViewModel.swift +++ b/Sources/ICCery/WizardViewModel.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import ICCeryCore /// Wizard state machine + artefact gating (issue #4, docs/06). @@ -10,47 +10,46 @@ import ICCeryCore /// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` — /// navigation is disk, not buttons. @MainActor -@Observable -final class WizardViewModel { +final class WizardViewModel: ObservableObject { // MARK: - wizardState fields (persisted) - var stage: WizardStage { + @Published var stage: WizardStage { didSet { if stage != oldValue { persist() } } } /// `wizardState.basename` — empty until a real artefact names it (#60). - var basename: String { + @Published var basename: String { didSet { if basename != oldValue { refreshGating(); persist() } } } /// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59). - var workingDirectory: URL? { + @Published var workingDirectory: URL? { didSet { if workingDirectory != oldValue { refreshGating(); persist() } } } - var printerName: String? { + @Published var printerName: String? { didSet { if printerName != oldValue { persist() } } } - var sessionMode: SessionMode { + @Published var sessionMode: SessionMode { didSet { if sessionMode != oldValue { persist() } } } /// `profileBasename` may differ after a `.ti3` import (#94). - var profileBasename: String? { + @Published var profileBasename: String? { didSet { if profileBasename != oldValue { persist() } } } /// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29). - var calibrationOriginalBasename: String { + @Published var calibrationOriginalBasename: String { didSet { if calibrationOriginalBasename != oldValue { persist() } } } // MARK: - Ephemeral /// Banner notice currently displayed (`#wizardNotification`). - var notice: Notice? + @Published var notice: Notice? /// Current artefact probe result; recomputed on `refreshGating()`. - private(set) var artefacts = StageArtefacts() + @Published private(set) var artefacts = StageArtefacts() /// Whether the 3D gamut viewer sheet is open (issue #28). - var showingGamutViewer = false + @Published var showingGamutViewer = false /// Optional `.gam` URL to show alongside the sRGB reference. - var gamutProfileURL: URL? + @Published var gamutProfileURL: URL? private let stateStore: WizardStateStore private var noticeDismissTask: Task? diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift index 0f035da..b4fe1ba 100644 --- a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -71,10 +71,14 @@ final class ColorSyncSuppressorTests: XCTestCase { s.modeResolver = { name in if Self.missing.contains(name) { return nil } Self.currentSymbol = name + // `Self` inside a @convention(c) closure is a dynamic-Self + // capture — spell the (final) class name instead. return { _, modeArg in - Self.recorded.append((Self.currentSymbol, modeArg as String)) - if let ok = Self.succeeding, - Self.currentSymbol == ok.0, (modeArg as String) == ok.1 { + ColorSyncSuppressorTests.recorded.append( + (ColorSyncSuppressorTests.currentSymbol, modeArg as String)) + if let ok = ColorSyncSuppressorTests.succeeding, + ColorSyncSuppressorTests.currentSymbol == ok.0, + (modeArg as String) == ok.1 { return 0 } return 1 -- 2.39.5 From 9857ddb4d068d9275e30ad3de19f26832aa0e1a1 Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 11 Sep 2026 22:38:30 +0100 Subject: [PATCH 05/25] test(m9): retry dropped calibration generate tap in UI test Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/ICCeryUITests/Milestone6CalibrationUITests.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift index 450fe4a..6d20785 100644 --- a/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift +++ b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift @@ -76,7 +76,14 @@ final class Milestone6CalibrationUITests: XCTestCase { // After generation the wizard should advance to Stage 2 (layout) because // a CAL_ .ti1 now exists and the session is in calibration mode. let layout = app.buttons["btnCreateLayout"] - XCTAssertTrue(layout.waitForExistence(timeout: 25)) + if !layout.waitForExistence(timeout: 25) { + // The generate tap can be dropped while the dashboard is still + // settling after the stage transition; retry once before failing. + if calGenerate.waitForExistence(timeout: 2) { + calGenerate.tap() + } + XCTAssertTrue(layout.waitForExistence(timeout: 25)) + } } /// A failing calibration targen surfaces the error through the -- 2.39.5 From 7c6ad1e6ceaa8a3fd310b0ded4d1f53fdba159be Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 08:34:37 +0100 Subject: [PATCH 06/25] fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109) brew install xcodegen resolves to a formula requiring Xcode 15.3, which cannot be installed on the macOS 12 runner (Xcode 14.2), failing run 31390 before project generation. Install a pinned prebuilt XcodeGen 2.38.0 release via scripts/ensure-host-tools.sh, shared by the build-and-test and package jobs (package-release.sh also runs xcodegen). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitea/workflows/macos.yml | 12 ++++++++--- scripts/ensure-host-tools.sh | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100755 scripts/ensure-host-tools.sh diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index 7a0b901..b75e74e 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -24,10 +24,11 @@ jobs: - name: Assert Xcode 14 toolchain run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1) + # Homebrew's xcodegen formula requires Xcode 15.3, which cannot be + # installed on macOS 12 (#109). The script installs a pinned + # prebuilt release instead. - name: Ensure host tools - run: | - command -v xcodegen || brew install xcodegen - python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild + run: scripts/ensure-host-tools.sh - name: Generate Xcode project run: xcodegen generate --spec project.yml @@ -134,6 +135,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # scripts/package-release.sh runs `xcodegen generate` and dmgbuild; + # see build-and-test for why brew is not used on macOS 12 (#109). + - name: Ensure host tools + run: scripts/ensure-host-tools.sh + - name: Package release run: scripts/package-release.sh env: diff --git a/scripts/ensure-host-tools.sh b/scripts/ensure-host-tools.sh new file mode 100755 index 0000000..9c0afef --- /dev/null +++ b/scripts/ensure-host-tools.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# scripts/ensure-host-tools.sh +# +# Bootstrap host tools needed by CI on the macOS 12 runner: +# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current +# formula requires Xcode 15.3, which cannot be installed on macOS 12). +# - dmgbuild: via pip3 (used by scripts/package-release.sh). +# +# Safe to run repeatedly: existing tools are left alone. + +set -eu + +XCODEGEN_VERSION="2.38.0" +INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}" + +echo "==> Ensuring dmgbuild" +python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild + +if command -v xcodegen >/dev/null 2>&1; then + echo "==> xcodegen already on PATH: $(xcodegen --version)" +else + echo "==> Installing xcodegen $XCODEGEN_VERSION (prebuilt)" + TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + ZIP="$TMP/xcodegen-$XCODEGEN_VERSION.zip" + curl -fL --retry 3 \ + "https://github.com/yonaskolb/XcodeGen/releases/download/$XCODEGEN_VERSION/xcodegen.zip" \ + -o "$ZIP" + rm -rf "$INSTALL_ROOT" + mkdir -p "$INSTALL_ROOT" + # Zip contains xcodegen/{bin/xcodegen,share/xcodegen/SettingPresets}; + # XcodeGen resolves its presets relative to the binary, so keep the tree. + unzip -q "$ZIP" -d "$INSTALL_ROOT" + BIN_DIR="$INSTALL_ROOT/xcodegen/bin" + chmod +x "$BIN_DIR/xcodegen" + if [ -n "${GITHUB_PATH:-}" ]; then + echo "$BIN_DIR" >> "$GITHUB_PATH" + fi + PATH="$BIN_DIR:$PATH" + echo "==> Installed: $("$BIN_DIR/xcodegen" --version)" +fi -- 2.39.5 From 55dee0f0cd6c51e8b2027c359f69f4641087074a Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 08:55:12 +0100 Subject: [PATCH 07/25] fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111) The formSection VStack had 11 direct children; Xcode 14.2's ViewBuilder only provides buildBlock overloads up to 10 arguments, so the CI build on the macOS 12 runner failed at line 144 with "extra argument in call" (run 31958). Nest the calibration toggle and conditional file row in an inner VStack with identical alignment and spacing. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Sources/ICCery/Stage4View.swift | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/Sources/ICCery/Stage4View.swift b/Sources/ICCery/Stage4View.swift index 2a400ac..676db3e 100644 --- a/Sources/ICCery/Stage4View.swift +++ b/Sources/ICCery/Stage4View.swift @@ -138,16 +138,20 @@ struct Stage4View: View { .textFieldStyle(.roundedBorder) .accessibilityIdentifier("colprofCopyright") - Toggle("Apply calibration curve", isOn: $model.applyCalibration) - .accessibilityIdentifier("colprofApplyCalibration") + // Nested VStack keeps the parent at the Swift 5.7 ViewBuilder + // 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111). + VStack(alignment: .leading, spacing: 12) { + Toggle("Apply calibration curve", isOn: $model.applyCalibration) + .accessibilityIdentifier("colprofApplyCalibration") - if model.applyCalibration { - HStack { - TextField("Calibration .cal file", text: $model.calibrationFile) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("colprofCalibrationFile") - Button("Browse…") { model.browseForCalibrationFile() } - .accessibilityIdentifier("btnBrowseCalibrationFile") + if model.applyCalibration { + HStack { + TextField("Calibration .cal file", text: $model.calibrationFile) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofCalibrationFile") + Button("Browse…") { model.browseForCalibrationFile() } + .accessibilityIdentifier("btnBrowseCalibrationFile") + } } } } -- 2.39.5 From c9bff50a1a1ea2877c875a45d7eb92ec450b982b Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 09:00:48 +0100 Subject: [PATCH 08/25] fix(m9): clear printTask without defer for Swift 5.7 (#113) Swift 5.7 on the macOS 12 CI runner (Xcode 14.2) rejects mutation of actor-isolated properties inside defer bodies, failing run 31978 at PrintSessionViewModel.swift:113 and :144. Clear printTask explicitly at each exit point of the print task closures. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Sources/ICCery/Print/PrintSessionViewModel.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index f1f59f7..e8e4ed3 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -110,7 +110,8 @@ final class PrintSessionViewModel: ObservableObject { isPrinting = true let task = Task { @MainActor [weak self] in guard let self else { return } - defer { self.printTask = nil } + // `defer` cannot mutate isolated state under Swift 5.7 + // (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113). var printed = 0 for page in result.pages { do { @@ -123,6 +124,7 @@ final class PrintSessionViewModel: ObservableObject { + error.localizedDescription ) isPrinting = false + self.printTask = nil return } } @@ -132,6 +134,7 @@ final class PrintSessionViewModel: ObservableObject { autoHideAfter: nil ) isPrinting = false + self.printTask = nil } printTask = task } @@ -141,7 +144,6 @@ final class PrintSessionViewModel: ObservableObject { isPrinting = true let task = Task { @MainActor [weak self] in guard let self else { return } - defer { self.printTask = nil } do { try await spool(page, index: page.index, pageSize: pageSize) printNotice = Notice( @@ -156,6 +158,7 @@ final class PrintSessionViewModel: ObservableObject { ) } isPrinting = false + self.printTask = nil } printTask = task } -- 2.39.5 From 17ee5d6717d04b0f89855ba59ba1bd1684483013 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 09:06:45 +0100 Subject: [PATCH 09/25] test(m9): replace MainActor.assertIsolated for Swift 5.7 (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainActor.assertIsolated() requires Swift 5.9 (Xcode 15); the macOS 12 CI runner compiles the test bundle with Swift 5.7 and failed run 31985. Assert Thread.isMainThread instead — it checks the same contract (the coalesced log sink hops back to the main actor). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/ICCeryCoreTests/ProcessRunSupportTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift index a1edb9b..357f317 100644 --- a/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift +++ b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift @@ -21,7 +21,9 @@ final class ProcessRunSupportTests: XCTestCase { setRunning: { running.append($0) }, resetLog: { resets += 1 }, onLog: { batch in - MainActor.assertIsolated() + // MainActor.assertIsolated() needs Swift 5.9; the runner is + // on Xcode 14.2 (Swift 5.7) (#115). + XCTAssertTrue(Thread.isMainThread) received.append(contentsOf: batch) } ) { onLog in -- 2.39.5 From b5683aa36aa164ec88fc13b05b6e8b11889fc5cb Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 09:14:54 +0100 Subject: [PATCH 10/25] fix(m9): link ICCeryCore statically so the app launches standalone (#117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app and test bundles share the local ICCeryCore package, Xcode builds it as a dynamic package-product framework that is not embedded in ICCery.app — the app crashes in dyld at launch when run outside the test host (macOS 12 VM crash, run 31985 products). Declare the product .static so each consumer links it directly. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Packages/ICCeryCore/Package.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/ICCeryCore/Package.swift b/Packages/ICCeryCore/Package.swift index 5fde803..6bb5b81 100644 --- a/Packages/ICCeryCore/Package.swift +++ b/Packages/ICCeryCore/Package.swift @@ -5,7 +5,10 @@ let package = Package( name: "ICCeryCore", platforms: [.macOS(.v12)], products: [ - .library(name: "ICCeryCore", targets: ["ICCeryCore"]), + // Static so app + test bundles each link ICCeryCore directly — + // a dynamic package product framework is not embedded in the + // .app and dyld kills the app at launch on macOS 12 (#117). + .library(name: "ICCeryCore", type: .static, targets: ["ICCeryCore"]), ], targets: [ .target(name: "ICCeryCore"), -- 2.39.5 From a2e3f11e70f5f7e61a96baa2694bf90c35173db9 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 09:29:40 +0100 Subject: [PATCH 11/25] fix(m9): ad-hoc sign embedded ICCeryCore framework so test host launches (#119) --- .gitea/workflows/macos.yml | 13 +++++++++++++ Packages/ICCeryCore/Package.swift | 5 +---- project.yml | 7 +++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index b75e74e..b4d9b43 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -45,6 +45,19 @@ jobs: CODE_SIGNING_ALLOWED=YES \ CODE_SIGN_IDENTITY='-' + # Xcode embeds the shared ICCeryCore package framework into the app + # and the test bundle without signing it. Ad-hoc hosts still require + # every loaded dylib to carry a cdhash — dyld killed the test host at + # launch (run 31992) — so sign every embedded copy once the build is + # done (embed steps run after any build script phase) (#119). + - name: Sign package product frameworks + run: | + find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \ + | while IFS= read -r -d '' fw; do + echo "signing $fw" + codesign --force --sign - --timestamp=none "$fw" + done + - name: Test unit (ICCeryCoreTests) run: | XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" diff --git a/Packages/ICCeryCore/Package.swift b/Packages/ICCeryCore/Package.swift index 6bb5b81..5fde803 100644 --- a/Packages/ICCeryCore/Package.swift +++ b/Packages/ICCeryCore/Package.swift @@ -5,10 +5,7 @@ let package = Package( name: "ICCeryCore", platforms: [.macOS(.v12)], products: [ - // Static so app + test bundles each link ICCeryCore directly — - // a dynamic package product framework is not embedded in the - // .app and dyld kills the app at launch on macOS 12 (#117). - .library(name: "ICCeryCore", type: .static, targets: ["ICCeryCore"]), + .library(name: "ICCeryCore", targets: ["ICCeryCore"]), ], targets: [ .target(name: "ICCeryCore"), diff --git a/project.yml b/project.yml index 46bc694..bdd9fc8 100644 --- a/project.yml +++ b/project.yml @@ -62,6 +62,13 @@ targets: OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"] MACOSX_DEPLOYMENT_TARGET: "12.0" ARCHS: "$(ARCHS_STANDARD)" + # Debug builds sign ad-hoc; hardened-runtime library validation would + # reject the unsigned embedded ICCeryCore package framework when the + # test host launches (run 31992, #119). Release keeps validation and + # links the package statically anyway. + configs: + Debug: + DISABLE_LIBRARY_VALIDATION: YES ICCeryCoreTests: type: bundle.unit-test -- 2.39.5 From 97eafd11fb69078c308c2a052238ff9444536237 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 09:41:11 +0100 Subject: [PATCH 12/25] fix(m9): Debug entitlements file to disable library validation for test host (#119) --- Resources/ICCery.Debug.entitlements | 16 ++++++++++++++++ project.yml | 11 +++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 Resources/ICCery.Debug.entitlements diff --git a/Resources/ICCery.Debug.entitlements b/Resources/ICCery.Debug.entitlements new file mode 100644 index 0000000..1179d62 --- /dev/null +++ b/Resources/ICCery.Debug.entitlements @@ -0,0 +1,16 @@ + + + + + + com.apple.security.device.usb + + + com.apple.security.cs.disable-library-validation + + + diff --git a/project.yml b/project.yml index bdd9fc8..642e5c3 100644 --- a/project.yml +++ b/project.yml @@ -19,6 +19,7 @@ targets: - path: Resources excludes: - ICCery.entitlements + - ICCery.Debug.entitlements - Argyll - path: Resources/Argyll type: folder @@ -63,12 +64,14 @@ targets: MACOSX_DEPLOYMENT_TARGET: "12.0" ARCHS: "$(ARCHS_STANDARD)" # Debug builds sign ad-hoc; hardened-runtime library validation would - # reject the unsigned embedded ICCeryCore package framework when the - # test host launches (run 31992, #119). Release keeps validation and - # links the package statically anyway. + # reject the embedded ICCeryCore package framework (no Team ID) when + # the test host launches (run 31992, #119). DISABLE_LIBRARY_VALIDATION + # does not inject the entitlement on Xcode 14.2, so use a dedicated + # Debug entitlements file. Release keeps validation and links the + # package statically anyway. configs: Debug: - DISABLE_LIBRARY_VALIDATION: YES + CODE_SIGN_ENTITLEMENTS: Resources/ICCery.Debug.entitlements ICCeryCoreTests: type: bundle.unit-test -- 2.39.5 From 7fd93247cbcf6465ee14a1926cbb94a0b4a28163 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 11:31:43 +0100 Subject: [PATCH 13/25] ci(m9): accept Xcode >= 14 in toolchain assert (#123) The grep on "Xcode 14." pinned CI to the 14.x series, blocking builds on macOS 14 / Xcode 16 runners. Parse the major version and require >= 14 instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitea/workflows/macos.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index b4d9b43..a65dc1b 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -21,8 +21,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Assert Xcode 14 toolchain - run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1) + - name: Assert Xcode 14+ toolchain + run: | + line="$(xcodebuild -version | head -1)" + major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')" + if [ -z "$major" ] || [ "$major" -lt 14 ]; then + echo "Unexpected Xcode version: $line" >&2; exit 1 + fi + echo "$line" # Homebrew's xcodegen formula requires Xcode 15.3, which cannot be # installed on macOS 12 (#109). The script installs a pinned -- 2.39.5 From 5134e82e63ea7df43a7862f576f15b8826706b06 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 11:39:16 +0100 Subject: [PATCH 14/25] fix(m9): stable 'sidebar' a11y identifier for AboutHelp UI test (#122) Xcode 14.2 / SDK 13.1 does not surface the sidebar root as an AXGroup, so the groups-containing query in testHelpOverlaysDoNotChangeSidebarHeight found nothing (run 32029). Give the sidebar root a stable identifier and query it directly. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Sources/ICCery/SidebarView.swift | 1 + Tests/ICCeryUITests/AboutHelpUITests.swift | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 0c4d3c1..f7928b0 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -129,6 +129,7 @@ struct SidebarView: View { } .frame(width: Theme.Metrics.sidebarWidth) .background(Theme.panel) + .accessibilityIdentifier("sidebar") } } diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift index ce70d58..f928b6e 100644 --- a/Tests/ICCeryUITests/AboutHelpUITests.swift +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -64,7 +64,8 @@ final class AboutHelpUITests: XCTestCase { let toggle = app.buttons["btnToggleAllHelp"] XCTAssertTrue(toggle.waitForExistence(timeout: 10)) - let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element + let sidebar = app.descendants(matching: .any)["sidebar"] + XCTAssertTrue(sidebar.waitForExistence(timeout: 10)) let before = sidebar.frame toggle.click() -- 2.39.5 From b7ac4f92ed0a312bee4d7b02f3967cab3fb8087c Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 12:22:53 +0100 Subject: [PATCH 15/25] ci(m9): upload UI-test xcresult on failure for a11y triage (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI failures on the self-hosted runner are hard to triage from stdout alone — the xcresult already contains the a11y hierarchy snapshot and screenshots. Upload Logs/Test when build-and-test fails. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitea/workflows/macos.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index a65dc1b..d1dfe69 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -146,6 +146,30 @@ jobs: echo "warning: skipping UI tests after repeated runner attach/activate failures" exit 0 + # XCTest stores the a11y hierarchy snapshot and screenshots in the + # xcresult on failure — upload it so UI failures can be triaged + # without access to the runner (#126). + - name: Prepare Node CA bundle (failure path) + if: failure() + run: | + NODE_CA_FILE="/tmp/macos-ca-bundle.pem" + security find-certificate -a -p \ + /System/Library/Keychains/SystemRootCertificates.keychain \ + /Library/Keychains/System.keychain \ + > "$NODE_CA_FILE" 2>/dev/null || true + if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then + cp /etc/ssl/cert.pem "$NODE_CA_FILE" + fi + + - name: Upload UI test xcresult + if: failure() + uses: actions/upload-artifact@v3 + env: + NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem + with: + name: ui-test-xcresult + path: build/DerivedData-test/Logs/Test + package: needs: build-and-test runs-on: macos-12 -- 2.39.5 From caeafb22be02f74169eaa9f48ede08bf0e73a1e4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 12:30:44 +0100 Subject: [PATCH 16/25] test(m9): dump a11y hierarchy to log on UI probe failure (#128) The probe fails on the runner with an apparently empty accessibility tree; the xcresult artifact is not retrievable via the API. Print app.debugDescription + window count so the tree lands in the job log. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/ICCeryUITests/AboutHelpUITests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift index f928b6e..2a6ba18 100644 --- a/Tests/ICCeryUITests/AboutHelpUITests.swift +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -46,7 +46,12 @@ final class AboutHelpUITests: XCTestCase { launchApp() let openAbout = app.buttons["openAboutBtn"] - XCTAssertTrue(openAbout.waitForExistence(timeout: 10)) + if !openAbout.waitForExistence(timeout: 10) { + // CI triage (#128): print the a11y tree so an empty or + // unexpected hierarchy shows up directly in the job log. + print("AXTREE-BEGIN windows=\(app.windows.count)\n\(app.debugDescription)\nAXTREE-END") + } + XCTAssertTrue(openAbout.exists) openAbout.click() _ = waitFor("aboutVersion", timeout: 10) -- 2.39.5 From a975f1a4e4638dc0684a10c4928fadd98d37a7b2 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 12:37:21 +0100 Subject: [PATCH 17/25] fix(m9): revert sidebar container a11y id; measure presetSelect frame (#130) On SDK 13.1 the 'sidebar' identifier on the root VStack propagates to every child, erasing openSettingsBtn/openAboutBtn/btnToggleAllHelp (a11y dump, run 32201). Revert it; the test now compares presetSelect frame before/after toggling help overlays. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Sources/ICCery/SidebarView.swift | 1 - Tests/ICCeryUITests/AboutHelpUITests.swift | 15 +++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index f7928b0..0c4d3c1 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -129,7 +129,6 @@ struct SidebarView: View { } .frame(width: Theme.Metrics.sidebarWidth) .background(Theme.panel) - .accessibilityIdentifier("sidebar") } } diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift index 2a6ba18..4fcd1af 100644 --- a/Tests/ICCeryUITests/AboutHelpUITests.swift +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -69,15 +69,18 @@ final class AboutHelpUITests: XCTestCase { let toggle = app.buttons["btnToggleAllHelp"] XCTAssertTrue(toggle.waitForExistence(timeout: 10)) - let sidebar = app.descendants(matching: .any)["sidebar"] - XCTAssertTrue(sidebar.waitForExistence(timeout: 10)) - let before = sidebar.frame + // SDK 13.1 emits no AXGroup for the sidebar root, and an + // identifier on the container clobbers child identifiers + // (#130) — measure a stable sidebar child instead. + let sidebarChild = app.descendants(matching: .any)["presetSelect"] + XCTAssertTrue(sidebarChild.waitForExistence(timeout: 10)) + let before = sidebarChild.frame toggle.click() - let after = sidebar.frame + let after = sidebarChild.frame - XCTAssertEqual(before.size.height, after.size.height, - "Toggling global help must not reflow the sidebar height.") + XCTAssertEqual(before, after, + "Toggling global help must not reflow the sidebar.") XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists) } } -- 2.39.5 From b5250591eab2af86a5767d0efc6ad03418c87dce Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 13:02:24 +0100 Subject: [PATCH 18/25] test(m9): scroll stage-2 explicitly before per-page print click (#132) btnPrintPage-0 sits at the window's bottom edge on the 1280x800 runner; XCUI's automatic scroll-to-visible runs but never moves the frame (run 32209). Scroll the stage-2 ScrollView manually until hittable. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/ICCeryUITests/Milestone3UITests.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index ad4a294..cf70a92 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -198,7 +198,18 @@ final class Milestone3UITests: XCTestCase { } XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) - app.buttons["btnPrintPage-0"].click() + // The gallery cell's Print button can sit at the window's bottom + // edge where XCUI's automatic scroll-to-visible is inert (#132) + // — scroll stage-2 explicitly until the hit point is onscreen. + let printPage = app.buttons["btnPrintPage-0"] + let stage2 = app.scrollViews["stage-2"] + let scrollDeadline = Date().addingTimeInterval(10) + while Date() < scrollDeadline, !printPage.isHittable { + stage2.scroll(byDeltaX: 0, deltaY: -1) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + XCTAssertTrue(printPage.isHittable) + printPage.click() let argv = waitForLpLine() XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) XCTAssertTrue(argv.contains("page1.tif"), argv) -- 2.39.5 From 67199877a20c3812c358e79b7da5d69f160bef3d Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 13:30:04 +0100 Subject: [PATCH 19/25] test(m9): fall back to coordinate click for per-page print button (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit stage-2 scrolls are synthesized but never move the LazyVGrid cell's a11y frame (run 32235). If still not hittable, dump the a11y tree and click the reported point directly — the lp argv assertion verifies the click landed. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/ICCeryUITests/Milestone3UITests.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index cf70a92..b95584b 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -208,8 +208,16 @@ final class Milestone3UITests: XCTestCase { stage2.scroll(byDeltaX: 0, deltaY: -1) RunLoop.current.run(until: Date().addingTimeInterval(0.2)) } - XCTAssertTrue(printPage.isHittable) - printPage.click() + if printPage.isHittable { + printPage.click() + } else { + // LazyVGrid cells can report a stale a11y frame — click the + // point directly; the lp argv assert below still verifies. + print("AXTREE-BEGIN frame=\(printPage.frame)\n" + + "\(app.debugDescription)\nAXTREE-END") + printPage.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.5)).click() + } let argv = waitForLpLine() XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) XCTAssertTrue(argv.contains("page1.tif"), argv) -- 2.39.5 From 7c5a5e779870c6ece53bfd3ceec930bbab255d76 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 14:03:59 +0100 Subject: [PATCH 20/25] test(m9): drive scroller + state-driven triggers in UI suite (#135) btnPrintPage-0 reports an in-viewport a11y frame but stays unhittable and scroll-wheel events on the LazyVGrid are inert; drag the vertical AXScrollBar thumb to perform a real scroll before clicking. The handheld chartread test could drop a btnTrigger click racing the strip state transition; poll btnTrigger until btnDoneRead appears instead of assuming exactly two strips. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/ICCeryUITests/Milestone3UITests.swift | 29 ++++++++++++------ Tests/ICCeryUITests/Milestone4UITests.swift | 33 +++++++++++---------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index b95584b..8ea1f86 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -198,15 +198,26 @@ final class Milestone3UITests: XCTestCase { } XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) - // The gallery cell's Print button can sit at the window's bottom - // edge where XCUI's automatic scroll-to-visible is inert (#132) - // — scroll stage-2 explicitly until the hit point is onscreen. - let printPage = app.buttons["btnPrintPage-0"] - let stage2 = app.scrollViews["stage-2"] - let scrollDeadline = Date().addingTimeInterval(10) - while Date() < scrollDeadline, !printPage.isHittable { - stage2.scroll(byDeltaX: 0, deltaY: -1) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + // The gallery cell's Print button sits at the window's bottom + // edge where synthesized scroll-wheel events are inert on the + // LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar + // thumb instead — a real scroll that re-renders the cell onscreen. + var printPage = app.buttons["btnPrintPage-0"] + let scrollDeadline = Date().addingTimeInterval(15) + while !printPage.isHittable, Date() < scrollDeadline { + let scroller = app.scrollBars.allElementsBoundByIndex + .first { $0.frame.height > $0.frame.width } + if let scroller { + scroller.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.1)) + .press(forDuration: 0.1, thenDragTo: + scroller.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.6))) + } else { + app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + printPage = app.buttons["btnPrintPage-0"] } if printPage.isHittable { printPage.click() diff --git a/Tests/ICCeryUITests/Milestone4UITests.swift b/Tests/ICCeryUITests/Milestone4UITests.swift index 66fda1e..6d4bc15 100644 --- a/Tests/ICCeryUITests/Milestone4UITests.swift +++ b/Tests/ICCeryUITests/Milestone4UITests.swift @@ -111,16 +111,9 @@ final class Milestone4UITests: XCTestCase { } app.buttons["btnCalibrate"].click() - // Trigger strip A. - _ = waitFor("btnTrigger", timeout: 20) - app.buttons["btnTrigger"].click() - - // Trigger strip B. - _ = waitFor("btnTrigger", timeout: 20) - app.buttons["btnTrigger"].click() - - // All strips read → Done & Save appears. - _ = waitFor("btnDoneRead", timeout: 20) + // Trigger each strip until all are read → Done & Save appears. + driveStripsUntilDone() + XCTAssertTrue(element("btnDoneRead").exists) app.buttons["btnDoneRead"].firstMatch.click() // Averaging panel appears with one pass snapshot. @@ -186,11 +179,21 @@ final class Milestone4UITests: XCTestCase { start.click() _ = waitFor("btnCalibrate", timeout: 25) app.buttons["btnCalibrate"].click() - _ = waitFor("btnTrigger", timeout: 20) - app.buttons["btnTrigger"].click() - _ = waitFor("btnTrigger", timeout: 20) - app.buttons["btnTrigger"].click() - _ = waitFor("btnDoneRead", timeout: 20) + driveStripsUntilDone() + XCTAssertTrue(element("btnDoneRead").exists) app.buttons["btnDoneRead"].firstMatch.click() } + + /// Clicks Trigger for each remaining strip until `btnDoneRead` + /// appears — the button is re-polled each pass so a click that + /// races a state transition isn't lost. + private func driveStripsUntilDone() { + let deadline = Date().addingTimeInterval(40) + while !element("btnDoneRead").exists, Date() < deadline { + if app.buttons["btnTrigger"].waitForExistence(timeout: 10) { + app.buttons["btnTrigger"].click() + } + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + } } -- 2.39.5 From 5c8973de735ffcf95b9543ef9e0c957c9837d2f1 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 14:33:26 +0100 Subject: [PATCH 21/25] test(m9): query presetSelect as PopUpButton to avoid duplicate match (#137) On the runner SDK the Picker label inherits the control accessibility identifier, so the .any query for presetSelect matches both a StaticText and the PopUpButton and .frame resolution fails. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/ICCeryUITests/AboutHelpUITests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift index 4fcd1af..b44cd0d 100644 --- a/Tests/ICCeryUITests/AboutHelpUITests.swift +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -71,8 +71,10 @@ final class AboutHelpUITests: XCTestCase { // SDK 13.1 emits no AXGroup for the sidebar root, and an // identifier on the container clobbers child identifiers - // (#130) — measure a stable sidebar child instead. - let sidebarChild = app.descendants(matching: .any)["presetSelect"] + // (#130) — measure a stable sidebar child instead. Query the + // pop-up by type: the Picker's "Preset" label inherits the same + // identifier, so an .any query matches twice. + let sidebarChild = app.popUpButtons["presetSelect"] XCTAssertTrue(sidebarChild.waitForExistence(timeout: 10)) let before = sidebarChild.frame -- 2.39.5 From 17c47af9f14d8f076ecbd5e9d4b37635516f7eb2 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 15:59:20 +0100 Subject: [PATCH 22/25] ci(m9): build tests for host arch only; reserve universal for release packaging The CI runner only executes tests on its own architecture, so building both slices wastes time. Override ARCHS="$(uname -m)" in the workflow and update all docs to match. Universal builds remain required for release verification and dmgbuild packaging. --- .gitea/workflows/macos.yml | 7 +++++-- AGENTS.md | 3 ++- README.md | 11 ++++++++--- docs/26-v2-mac-ticket-plan.md | 4 ++-- docs/PREUAT.md | 2 +- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index d1dfe69..1635a5d 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -39,14 +39,17 @@ jobs: - name: Generate Xcode project run: xcodegen generate --spec project.yml - - name: Build for testing (universal) + # Tests only ever run on the runner's own architecture; build + # just that slice. Packaging (scripts/package-release.sh) still + # produces the universal Release binary. + - name: Build for testing (host arch) run: | xcodebuild build-for-testing \ -scheme ICCery \ -destination 'platform=macOS' \ -derivedDataPath "$DERIVED" \ -configuration Debug \ - ARCHS='arm64 x86_64' \ + ARCHS="$(uname -m)" \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGNING_ALLOWED=YES \ CODE_SIGN_IDENTITY='-' diff --git a/AGENTS.md b/AGENTS.md index c5077d8..6344dd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,9 +45,10 @@ PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` ## Verify ``` -xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO +xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)" codesign -dvv ``` +Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging. ## Private ColorSync SPI 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. diff --git a/README.md b/README.md index 77aeaba..59df0be 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,11 @@ Equivalent without Make: xcodegen generate xcodebuild test -scheme ICCery \ -destination 'platform=macOS' \ - ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO + ARCHS="$(uname -m)" ``` +`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`, so a plain `xcodebuild test` (and `make test`) builds universal; the `ARCHS="$(uname -m)"` override narrows it to the host slice. + Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing. ```bash @@ -124,10 +126,13 @@ docs/ functional spec + v2 ticket plan ## Tests ```bash -# full suite (universal) +# full suite (host arch) xcodebuild test -scheme ICCery \ -destination 'platform=macOS' \ - ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO + ARCHS="$(uname -m)" + +# to compile-check both slices instead: +# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO # examples xcodebuild test -scheme ICCery -destination 'platform=macOS' \ diff --git a/docs/26-v2-mac-ticket-plan.md b/docs/26-v2-mac-ticket-plan.md index 98d330f..03ce20c 100644 --- a/docs/26-v2-mac-ticket-plan.md +++ b/docs/26-v2-mac-ticket-plan.md @@ -66,7 +66,7 @@ Do not create issues until labels and milestones exist. - Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only. - Branching: `develop` ← `milestone/mN-` ← `feat/-`; PRs via Gitea MCP. - Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`. -- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`; sidecar `codesign -dvv`. +- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"` (host arch; universal reserved for release packaging); sidecar `codesign -dvv`. - Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. ### `BUILD-PLAN.md` @@ -566,7 +566,7 @@ Labels: `Feature/DevOps`, `Priority/High` Milestone: M6 - **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror. -- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO` → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact. +- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS="$(uname -m)"` (host arch; the dmgbuild leg still builds universal) → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact. - App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate. - Confirm entitlements: sandbox **false**. - Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md). diff --git a/docs/PREUAT.md b/docs/PREUAT.md index 8310d6d..e7041c1 100644 --- a/docs/PREUAT.md +++ b/docs/PREUAT.md @@ -17,7 +17,7 @@ Unsigned CI artefacts (e.g. a `.zip` from a non-notarized workflow run) are **no scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars xcodegen generate --project . xcodebuild test -scheme ICCery -destination 'platform=macOS' \ - ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO \ + ARCHS="$(uname -m)" \ CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-' ``` -- 2.39.5 From aa32cc45d34a9106d56d93a68c43da9006bd27aa Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 16:50:57 +0100 Subject: [PATCH 23/25] ci(m9): skip probe test in full UI suite leg (#139) The attach probe already runs testAboutDialogShowsVersionAndBuildDate; the full-suite leg reran it for ~30s of zero extra coverage. Add -skip-testing so it only runs in the probe leg. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitea/workflows/macos.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index 1635a5d..5a6b32c 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -133,7 +133,8 @@ jobs: echo "error: UI probe failed with a real test error" >&2 exit 1 fi - if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then + if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \ + -skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then exit 0 fi if is_runner_attach_failure; then -- 2.39.5 From 60972a0704a4fcff4d785f8a47308da52cc2df02 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 16:53:30 +0100 Subject: [PATCH 24/25] test(m9): replace full-timeout negative waits with dwell assertion (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two XCTAssertFalse(el.waitForExistence(timeout: 3)) sites in Milestone2UITests always burned the full 3s on the expected-absent path. Add assertAbsent(_:dwell:) (0.5s dwell + .exists) and use it at both sites — same coverage, ~6s back per suite run. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/ICCeryUITests/Milestone2UITests.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Tests/ICCeryUITests/Milestone2UITests.swift b/Tests/ICCeryUITests/Milestone2UITests.swift index 31bdded..4455f0a 100644 --- a/Tests/ICCeryUITests/Milestone2UITests.swift +++ b/Tests/ICCeryUITests/Milestone2UITests.swift @@ -85,6 +85,16 @@ final class Milestone2UITests: XCTestCase { return el } + /// Assert an element stays absent after a short dwell — unlike + /// `waitForExistence`, which always burns its full timeout on the + /// negative path. + private func assertAbsent(_ el: XCUIElement, dwell: TimeInterval = 0.5, + _ message: String = "expected element to stay absent", + file: StaticString = #filePath, line: UInt = #line) { + RunLoop.current.run(until: Date().addingTimeInterval(dwell)) + XCTAssertFalse(el.exists, message, file: file, line: line) + } + private func staticText(_ exact: String) -> XCUIElement { let inApp = app.staticTexts[exact] if inApp.exists { return inApp } @@ -316,7 +326,7 @@ final class Milestone2UITests: XCTestCase { "identifier BEGINSWITH 'btnDeletePreset-'") XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5)) deleteButtons.firstMatch.click() - XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3)) + assertAbsent(staticText("UI Test Preset")) } /// Export a preset to JSON and re-import it (issue #11). @@ -348,7 +358,7 @@ final class Milestone2UITests: XCTestCase { let deleteButtons = buttonsMatching( "identifier BEGINSWITH 'btnDeletePreset-'") deleteButtons.firstMatch.click() - XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3)) + assertAbsent(staticText("RoundTrip")) // Copy the export to the import path so the hook picks it up. try FileManager.default.copyItem(at: exportURL, to: importURL) -- 2.39.5 From 990c49a436cb5e7c50c06eb3005dfa4f7d40adfe Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 12 Sep 2026 16:55:56 +0100 Subject: [PATCH 25/25] test(m9): collapse ArgyllRunner waits under ICCERY_UI_TESTING (#141) Add a DEBUG-gated testAwareDelay helper (same ICCERY_UI_TESTING=1 convention as AppPaths.testRoot) and apply it to the ensureNotRunning 100ms poll and the XY chartread 500ms pre-kill wait. Under UI tests the intervals shrink ~10x; release builds compile the branch out entirely, so production timing is unchanged. No current UI test exercises XY cancel, so the 500ms site is future-proofing at zero risk today. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Sources/ICCeryCore/Argyll/ArgyllRunner.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift index c2eb510..fbbfb9e 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -192,6 +192,18 @@ public struct ArgyllRunner: Sendable { // MARK: - Shared collection + /// DEBUG-only fast path: under `ICCERY_UI_TESTING=1` polling/wait + /// intervals shrink ~10x — same env convention as `AppPaths.testRoot`. + /// Release builds compile the branch out entirely; no static state. + private static func testAwareDelay(_ nanos: UInt64) -> UInt64 { + #if DEBUG + if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" { + return nanos / 10 + } + #endif + return nanos + } + /// Cancels any previous child with the same id and waits for it to /// finalize, so `runStreaming` / `runCaptured` never sees a /// `duplicateID` from a leftover process (#50, #52). @@ -200,7 +212,7 @@ public struct ArgyllRunner: Sendable { await processManager.kill(id: id) var attempts = 0 while await processManager.isRunning(id), attempts < 30 { - try? await Task.sleep(nanoseconds: 100_000_000) + try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000)) attempts += 1 } } @@ -592,7 +604,7 @@ public struct ArgyllRunner: Sendable { await processManager.setPreKillHook(id: processId) { [processManager] in if isXY { try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) - try? await Task.sleep(nanoseconds: 500_000_000) + try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000)) } } -- 2.39.5