diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml index f82f138..5a6b32c 100644 --- a/.gitea/workflows/macos.yml +++ b/.gitea/workflows/macos.yml @@ -14,28 +14,59 @@ 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: | + 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 + # prebuilt release instead. + - name: Ensure host tools + run: scripts/ensure-host-tools.sh + - 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='-' + # 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)" @@ -102,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 @@ -118,14 +150,43 @@ 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-14 + runs-on: macos-12 if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') steps: - 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/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/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..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(for: .milliseconds(100)) + try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000)) attempts += 1 } } @@ -243,7 +255,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 +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(for: .milliseconds(500)) + try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000)) } } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift index fec3f09..8b0fc25 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift @@ -72,8 +72,8 @@ public enum CGATSParser { public static func parse( _ contents: String, sourceURL: URL? = nil - ) throws(CGATSParseError) -> CGATSDataset { - guard !contents.isEmpty else { throw .emptyFile } + ) throws -> CGATSDataset { + 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 @@ -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]() @@ -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/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/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/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/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..e8e4ed3 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) { @@ -111,7 +110,8 @@ final class PrintSessionViewModel { 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 { @@ -124,6 +124,7 @@ final class PrintSessionViewModel { + error.localizedDescription ) isPrinting = false + self.printTask = nil return } } @@ -133,6 +134,7 @@ final class PrintSessionViewModel { autoHideAfter: nil ) isPrinting = false + self.printTask = nil } printTask = task } @@ -142,7 +144,6 @@ final class PrintSessionViewModel { 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( @@ -157,6 +158,7 @@ final class PrintSessionViewModel { ) } isPrinting = false + self.printTask = nil } printTask = task } 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 2f81970..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 @@ -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/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..676db3e 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) { @@ -130,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") + } } } } 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 2bf9747..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? @@ -201,7 +200,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/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/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/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/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/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..b4fe1ba 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 { @@ -78,10 +71,14 @@ struct ColorSyncSuppressorTests { 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 @@ -90,55 +87,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/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.. 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/PresetTests.swift b/Tests/ICCeryCoreTests/PresetTests.swift index 7a5167a..3fa0083 100644 --- a/Tests/ICCeryCoreTests/PresetTests.swift +++ b/Tests/ICCeryCoreTests/PresetTests.swift @@ -1,116 +1,98 @@ -import Testing import Foundation +import XCTest @testable import ICCeryCore -@Suite("ProfilingPreset") -struct ProfilingPresetTests { +final class ProfilingPresetTests: XCTestCase { - @Test("snake_case keys round-trip through Codable") - func roundTrip() throws { + func testRoundTrip() throws { var p = PresetCatalog.highQualityCMYK p.colprofInputViewingCond = "D50_2" let data = try JSONEncoder().encode(p) let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) - #expect(decoded == p) + XCTAssertEqual(decoded, p) // Spot-check the wire format. let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] - #expect(obj["colour_space"] as? String == "cmyk") - #expect(obj["patch_count"] as? Int == 1500) - #expect(obj["total_ink_limit"] as? Int == 320) - #expect(obj["bit_depth"] as? Int == 16) - #expect(obj["colprof_input_viewing_cond"] as? String == "D50_2") + XCTAssertEqual(obj["colour_space"] as? String, "cmyk") + XCTAssertEqual(obj["patch_count"] as? Int, 1500) + XCTAssertEqual(obj["total_ink_limit"] as? Int, 320) + XCTAssertEqual(obj["bit_depth"] as? Int, 16) + XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2") } - @Test("Unknown keys ignored; missing required field fails") - func schemaTolerance() throws { + func testSchemaTolerance() throws { let json = """ {"id":"x","name":"N","colour_space":"rgb","patch_count":10, "white_patches":1,"black_patches":1,"instrument":"i1", "page_size":"A4","bit_depth":8,"dpi":300,"future_key":42} """.data(using: .utf8)! let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json) - #expect(ok.id == "x") + XCTAssertEqual(ok.id, "x") let missing = """ {"id":"x","name":"N","colour_space":"rgb"} """.data(using: .utf8)! - #expect(throws: DecodingError.self) { - try JSONDecoder().decode(ProfilingPreset.self, from: missing) - } + XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) } } - @Test("Validation rejects bad colour space / dpi / bit depth") - func validation() { - #expect(throws: ProfilingPreset.ValidationError.self) { - try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated() - } - #expect(throws: ProfilingPreset.ValidationError.self) { - try ProfilingPreset(id: "a", name: "n", dpi: 10).validated() - } - #expect(throws: ProfilingPreset.ValidationError.self) { - try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated() - } - #expect(throws: ProfilingPreset.ValidationError.self) { - try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated() - } + func testValidation() { + XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } + XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } + XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } + XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } } } -@Suite("PresetCatalog") -struct PresetCatalogTests { +final class PresetCatalogTests: XCTestCase { - @Test("Four built-ins with the documented values") - func builtIns() { - #expect(PresetCatalog.builtIns.count == 4) + func testBuiltIns() { + XCTAssertEqual(PresetCatalog.builtIns.count, 4) let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) }) let std = byID["preset-std-rgb"]! - #expect(std.colourSpace == "rgb" && std.patchCount == 800 + XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800 && std.pageSize == "A4" && std.bitDepth == 8 && std.dpi == 300 && std.colprofQuality == "m" && std.whitePatches == 4 && std.blackPatches == 4) let hq = byID["preset-hq-cmyk"]! - #expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500 + XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500 && hq.pageSize == "A3" && hq.bitDepth == 16 && hq.dpi == 300 && hq.colprofQuality == "h" && hq.totalInkLimit == 320 && hq.blackPatches == 8) let draft = byID["preset-draft-rgb"]! - #expect(draft.colourSpace == "rgb" && draft.patchCount == 400 + XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400 && draft.pageSize == "A4" && draft.bitDepth == 8 && draft.dpi == 150 && draft.colprofQuality == "l") let ultra = byID["preset-ultra-rgb"]! - #expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500 + XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500 && ultra.pageSize == "A3" && ultra.bitDepth == 16 && ultra.dpi == 300 && ultra.colprofQuality == "u" && ultra.ofpsHighQuality == true && ultra.whitePatches == 6 && ultra.blackPatches == 6) for p in PresetCatalog.builtIns { - #expect(p.instrument == "i1") - #expect(p.colprofFwa == "D50") - #expect(p.randomSeed == 1) - #expect(p.noRandomize == false) - #expect(p.colprofAlgorithm == "l") + XCTAssertEqual(p.instrument, "i1") + XCTAssertEqual(p.colprofFwa, "D50") + XCTAssertEqual(p.randomSeed, 1) + XCTAssertEqual(p.noRandomize, false) + XCTAssertEqual(p.colprofAlgorithm, "l") } } - @Test("Custom presets overlay by id; built-ins are not deletable") - func overlay() { + func testOverlay() { let custom = ProfilingPreset( id: "preset-std-rgb", name: "Shadowed", patchCount: 42) let all = PresetCatalog.all(custom: [custom]) - #expect(all.count == 4) - #expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42) - #expect(PresetCatalog.isBuiltIn("preset-std-rgb")) - #expect(!PresetCatalog.isBuiltIn("custom-1")) + XCTAssertEqual(all.count, 4) + XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42) + XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb")) + XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1")) } } -@Suite("PresetStore") -struct PresetStoreTests { +final class PresetStoreTests: XCTestCase { private func tempSettingsURL() throws -> 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/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..a64df4c 100644 --- a/Tests/ICCeryCoreTests/PrinttargTests.swift +++ b/Tests/ICCeryCoreTests/PrinttargTests.swift @@ -1,9 +1,8 @@ -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 +27,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 +150,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,66 +205,58 @@ 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) } } } } -@Suite("ArgyllRunner Printtarg") -struct ArgyllRunnerPrinttargTests { +final class ArgyllRunnerPrinttargTests: XCTestCase { private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL { let dir = FileManager.default.temporaryDirectory @@ -336,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="" @@ -356,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 @@ -377,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="" @@ -398,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' @@ -415,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="" @@ -442,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 716bf92..261b5a9 100644 --- a/Tests/ICCeryCoreTests/ProcessManagerTests.swift +++ b/Tests/ICCeryCoreTests/ProcessManagerTests.swift @@ -1,11 +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 @@ -43,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) } } } @@ -84,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 } @@ -93,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 } @@ -106,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") @@ -122,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", @@ -149,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. @@ -176,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") @@ -199,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( @@ -249,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)") @@ -279,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 @@ -289,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", @@ -315,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)") @@ -390,83 +402,88 @@ 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) } } -@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/ProcessRunSupportTests.swift b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift index 79ebe1d..357f317 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] = [] @@ -23,7 +21,9 @@ struct ProcessRunSupportTests { 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 @@ -31,20 +31,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 +55,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/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/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 821efce..ce2f2d8 100644 --- a/Tests/ICCeryCoreTests/TargenTests.swift +++ b/Tests/ICCeryCoreTests/TargenTests.swift @@ -1,12 +1,10 @@ -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 +13,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 +26,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 +38,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 +80,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 +93,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 +106,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 +119,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 +133,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 +148,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 +162,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 +175,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 +186,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 +199,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,17 +213,15 @@ 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) } } } -@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) } @@ -284,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) } @@ -318,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) } @@ -352,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/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/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) } } 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) + } + } +} diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift index ce70d58..b44cd0d 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) @@ -64,14 +69,20 @@ final class AboutHelpUITests: XCTestCase { let toggle = app.buttons["btnToggleAllHelp"] XCTAssertTrue(toggle.waitForExistence(timeout: 10)) - let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element - 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. 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 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) } } 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) diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index ad4a294..8ea1f86 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -198,7 +198,37 @@ final class Milestone3UITests: XCTestCase { } XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) - app.buttons["btnPrintPage-0"].click() + // 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() + } 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) 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)) + } + } } 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 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='-' ``` diff --git a/project.yml b/project.yml index e172855..642e5c3 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,12 +13,13 @@ targets: ICCery: type: application platform: macOS - deploymentTarget: "14.0" + deploymentTarget: "12.0" sources: - path: Sources/ICCery - path: Resources excludes: - ICCery.entitlements + - ICCery.Debug.entitlements - Argyll - path: Resources/Argyll type: folder @@ -46,7 +47,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 +59,24 @@ 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)" + # Debug builds sign ad-hoc; hardened-runtime library validation would + # 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: + CODE_SIGN_ENTITLEMENTS: Resources/ICCery.Debug.entitlements ICCeryCoreTests: type: bundle.unit-test platform: macOS - deploymentTarget: "14.0" + deploymentTarget: "12.0" sources: - path: Tests/ICCeryCoreTests dependencies: @@ -79,13 +89,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 +105,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: 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