Compare commits

...
Author SHA1 Message Date
gronod a2e3f11e70 fix(m9): ad-hoc sign embedded ICCeryCore framework so test host launches (#119) 2026-09-12 09:29:40 +01:00
gronod c3c9bbc5ba Merge pull request 'fix(m9): link ICCeryCore statically so the app launches standalone (#117)' (#118) from feat/117-static-iccerycore into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 26s
macOS CI / package (push) Skipped
Reviewed-on: #118
2026-09-12 09:17:22 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b5683aa36a fix(m9): link ICCeryCore statically so the app launches standalone (#117)
When the app and test bundles share the local ICCeryCore package, Xcode
builds it as a dynamic package-product framework that is not embedded
in ICCery.app — the app crashes in dyld at launch when run outside the
test host (macOS 12 VM crash, run 31985 products). Declare the product
.static so each consumer links it directly.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:14:54 +01:00
gronod 707455dcfb Merge pull request 'test(m9): replace MainActor.assertIsolated for Swift 5.7 (#115)' (#116) from feat/115-assertisolated-swift57 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 2m53s
macOS CI / package (push) Skipped
Reviewed-on: #116
2026-09-12 09:07:06 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 17ee5d6717 test(m9): replace MainActor.assertIsolated for Swift 5.7 (#115)
MainActor.assertIsolated() requires Swift 5.9 (Xcode 15); the macOS 12
CI runner compiles the test bundle with Swift 5.7 and failed run 31985.
Assert Thread.isMainThread instead — it checks the same contract (the
coalesced log sink hops back to the main actor).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:06:45 +01:00
gronod 2295bcdbae Merge pull request 'fix(m9): clear printTask without defer for Swift 5.7 (#113)' (#114) from feat/113-printtask-defer-swift57 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 1m59s
macOS CI / package (push) Skipped
Reviewed-on: #114
2026-09-12 09:01:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c9bff50a1a fix(m9): clear printTask without defer for Swift 5.7 (#113)
Swift 5.7 on the macOS 12 CI runner (Xcode 14.2) rejects mutation of
actor-isolated properties inside defer bodies, failing run 31978 at
PrintSessionViewModel.swift:113 and :144. Clear printTask explicitly at
each exit point of the print task closures.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:00:48 +01:00
gronod 1b3d3dd821 Merge pull request 'fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111)' (#112) from feat/111-stage4-viewbuilder-limit into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 1m28s
macOS CI / package (push) Skipped
Reviewed-on: #112
2026-09-12 08:55:57 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 55dee0f0cd fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111)
The formSection VStack had 11 direct children; Xcode 14.2's ViewBuilder
only provides buildBlock overloads up to 10 arguments, so the CI build
on the macOS 12 runner failed at line 144 with "extra argument in call"
(run 31958). Nest the calibration toggle and conditional file row in an
inner VStack with identical alignment and spacing.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 08:55:12 +01:00
gronod 45fd2b988f Merge pull request 'fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109)' (#110) from feat/109-ci-xcodegen-pin into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 2m34s
macOS CI / package (push) Skipped
Reviewed-on: #110
2026-09-12 08:35:51 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c6ad1e6ce fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109)
brew install xcodegen resolves to a formula requiring Xcode 15.3, which
cannot be installed on the macOS 12 runner (Xcode 14.2), failing run
31390 before project generation. Install a pinned prebuilt XcodeGen
2.38.0 release via scripts/ensure-host-tools.sh, shared by the
build-and-test and package jobs (package-release.sh also runs xcodegen).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 08:34:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 9857ddb4d0 test(m9): retry dropped calibration generate tap in UI test
macOS CI / build-and-test (push) Failing after 48s
macOS CI / package (push) Skipped
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 22:38:30 +01:00
gronod 7a6b82816b Merge pull request 'feat(m9): state management and view demotions for macOS 12 (Slice 4)' (#108) from feat/m9-slice4-state-and-views into milestone/m9-monterey 2026-09-11 21:57:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 32d2184d2e feat(m9): migrate state to Combine and demote SwiftUI views for macOS 12
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:51:18 +01:00
gronod a76120d9f7 Merge pull request 'test(m9): complete migration of stateful and async suites to XCTest (Slice 3)' (#107) from feat/m9-slice3-xctest-async-stateful into milestone/m9-monterey 2026-09-11 21:34:15 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a30a8fc551 test(m9): complete migration of all remaining test suites to XCTest
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:29:16 +01:00
gronod 83f3a4f0e2 Merge pull request 'test(m9): convert pure core suites to XCTest (Slice 2)' (#106) from feat/m9-slice2-xctest-core-pure into milestone/m9-monterey 2026-09-11 21:06:33 +01:00
43 changed files with 1102 additions and 977 deletions
+22 -3
View File
@@ -24,10 +24,11 @@ jobs:
- name: Assert Xcode 14 toolchain - name: Assert Xcode 14 toolchain
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1) run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead.
- name: Ensure host tools - name: Ensure host tools
run: | run: scripts/ensure-host-tools.sh
command -v xcodegen || brew install xcodegen
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
- name: Generate Xcode project - name: Generate Xcode project
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
@@ -44,6 +45,19 @@ jobs:
CODE_SIGNING_ALLOWED=YES \ CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY='-' 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) - name: Test unit (ICCeryCoreTests)
run: | run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
@@ -134,6 +148,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Package release
run: scripts/package-release.sh run: scripts/package-release.sh
env: env:
+3 -4
View File
@@ -3,8 +3,8 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07). /// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View { struct CalibrationView: View {
@Bindable var model: CalibrationViewModel @ObservedObject var model: CalibrationViewModel
@Bindable var wizard: WizardViewModel @ObservedObject var wizard: WizardViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -73,7 +73,7 @@ struct CalibrationView: View {
if let url = model.computedCalURL { if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() } .onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle") .accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)") Text("Loaded: \(url.lastPathComponent)")
@@ -96,7 +96,6 @@ struct CalibrationView: View {
} }
} }
} }
.formStyle(.grouped)
HStack { HStack {
Spacer() Spacer()
+12 -13
View File
@@ -1,13 +1,12 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and /// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves. /// compute `.cal` curves.
@MainActor @MainActor
@Observable final class CalibrationViewModel: ObservableObject {
final class CalibrationViewModel {
let workflow: TargetWorkflowViewModel let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel let profile: ProfileWorkflowViewModel
@@ -15,16 +14,16 @@ final class CalibrationViewModel {
// MARK: - Form state // MARK: - Form state
var colourSpace: ColourSpace = .cmyk @Published var colourSpace: ColourSpace = .cmyk
var steps: Int = 21 @Published var steps: Int = 21
var whitePatches: Int = 4 @Published var whitePatches: Int = 4
var includeNeutralEmphasis: Bool = false @Published var includeNeutralEmphasis: Bool = false
var inkLimit: String = "320" @Published var inkLimit: String = "320"
var applyToProfile: Bool = false @Published var applyToProfile: Bool = false
var computedCalURL: URL? @Published var computedCalURL: URL?
var calibrationLog: [String] = [] @Published var calibrationLog: [String] = []
var isGenerating = false @Published var isGenerating = false
var isComputing = false @Published var isComputing = false
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow self.workflow = workflow
+30 -7
View File
@@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder {
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* /// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
/// (blue-yellow) is depth. /// (blue-yellow) is depth.
struct GamutView: View { struct GamutView: View {
@State private var viewModel: GamutViewModel @StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {} @State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool @FocusState private var isFocused: Bool
init(profileGamURL: URL? = nil) { init(profileGamURL: URL? = nil) {
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) _viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
} }
var body: some View { var body: some View {
@@ -87,11 +87,6 @@ struct GamutView: View {
) )
.focusable() .focusable()
.focused($isFocused) .focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.onAppear { isFocused = true } .onAppear { isFocused = true }
VStack { VStack {
@@ -148,6 +143,7 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView context.coordinator.scnView = scnView
context.coordinator.scene = scene context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.installKeyMonitor()
return scnView return scnView
} }
@@ -168,6 +164,7 @@ private struct GamutSceneView: NSViewRepresentable {
} }
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor()
nsView.isPlaying = false nsView.isPlaying = false
} }
@@ -175,6 +172,7 @@ private struct GamutSceneView: NSViewRepresentable {
final class Coordinator: NSObject { final class Coordinator: NSObject {
weak var scnView: SCNView? weak var scnView: SCNView?
weak var scene: SCNScene? weak var scene: SCNScene?
private var keyMonitor: Any?
private let profileNode = SCNNode() private let profileNode = SCNNode()
private let referenceGroup = SCNNode() private let referenceGroup = SCNNode()
@@ -431,6 +429,31 @@ private struct GamutSceneView: NSViewRepresentable {
scnView?.isPlaying = false 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() { func resetCamera() {
guard let scnView else { return } guard let scnView else { return }
+6 -7
View File
@@ -1,26 +1,25 @@
import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
import Observation
/// View model for the native SceneKit gamut viewer. /// View model for the native SceneKit gamut viewer.
/// ///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a /// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory. /// printer/profile `.gam` from the current working directory.
@MainActor @MainActor
@Observable final class GamutViewModel: ObservableObject {
final class GamutViewModel {
/// Parsed reference sRGB gamut mesh. /// Parsed reference sRGB gamut mesh.
var sRGBMesh: GamutMesh? @Published var sRGBMesh: GamutMesh?
/// Parsed printer/profile gamut mesh. /// Parsed printer/profile gamut mesh.
var profileMesh: GamutMesh? @Published var profileMesh: GamutMesh?
/// User-facing status line. /// User-facing status line.
var status = "Loading gamut…" @Published var status = "Loading gamut…"
/// Closure injected into the SceneKit view to request a camera reset. /// Closure injected into the SceneKit view to request a camera reset.
var resetCamera: () -> Void = {} @Published var resetCamera: () -> Void = {}
private let profileGamURL: URL? private let profileGamURL: URL?
+26 -10
View File
@@ -5,11 +5,11 @@ import SwiftUI
@main @main
struct ICCeryApp: App { struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var workflow: TargetWorkflowViewModel @StateObject private var workflow: TargetWorkflowViewModel
init() { init() {
let environment = AppEnvironment.live() let environment = AppEnvironment.live()
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment)) _workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
try? AppPaths.ensureDirectories() try? AppPaths.ensureDirectories()
// Log level is runtime state apply persisted settings at // Log level is runtime state apply persisted settings at
// startup (#158); the Settings sheet re-applies on save. // startup (#158); the Settings sheet re-applies on save.
@@ -17,15 +17,17 @@ struct ICCeryApp: App {
} }
var body: some Scene { var body: some Scene {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700). // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
Window("ICCery", id: "main") { // metrics are applied by AppDelegate once the window exists.
WindowGroup("ICCery") {
RootView(workflow: workflow) RootView(workflow: workflow)
.frame(minWidth: 1100, minHeight: 700) .frame(minWidth: 1100, minHeight: 700)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
} }
.defaultSize(width: 1280, height: 800) .commands {
.windowResizability(.contentMinSize) // Single-window app: no File > New window.
.defaultPosition(.center) CommandGroup(replacing: .newItem) {}
}
} }
} }
@@ -37,16 +39,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false private var terminationRequested = false
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
// SwiftUI `Window` scenes launched by XCTest stay // SwiftUI scenes launched by XCTest stay `.runningBackground`
// `.runningBackground` unless the app takes regular activation // unless the app takes regular activation and orders the window
// and orders the window front (CI run 29804). // front (CI run 29804).
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
for window in NSApp.windows { for window in NSApp.windows {
configureMainWindow(window)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
} }
NSApp.activate(ignoringOtherApps: true) 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 { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true true
} }
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
@@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable {
/// Stage 3 workflow state and interaction (issues #18#22). /// Stage 3 workflow state and interaction (issues #18#22).
@MainActor @MainActor
@Observable final class MeasurementWorkflowViewModel: ObservableObject {
final class MeasurementWorkflowViewModel {
// MARK: - Authorities // MARK: - Authorities
@@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel {
// MARK: - Settings-driven thresholds // MARK: - Settings-driven thresholds
private(set) var goodMax: Double = 2.0 @Published private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0 @Published private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false @Published private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection // MARK: - Instrument detection
var instruments: [InstrumentDevice] = [] @Published var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto @Published var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false @Published var isDetecting = false
var detectionError: String? @Published var detectionError: String?
// MARK: - Chartread session // MARK: - Chartread session
var isChartreadRunning = false @Published var isChartreadRunning = false
var chartreadState: ChartreadState = .idle @Published var chartreadState: ChartreadState = .idle
var currentPrompt: String? @Published var currentPrompt: String?
var requestedWarningKey: String? @Published var requestedWarningKey: String?
var chartreadLog: [String] = [] @Published var chartreadLog: [String] = []
var rows: [ChartreadRow] = [] @Published var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = [] @Published var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false @Published var showRemoveSheetNotice = false
/// Stage-local chartread error notice (`#chartreadLastError`, #80). /// Stage-local chartread error notice (`#chartreadLastError`, #80).
var chartreadNotice: Notice? @Published var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>? private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging // MARK: - Averaging
var passSnapshots: [URL] = [] @Published var passSnapshots: [URL] = []
var isFinishing = false @Published var isFinishing = false
var finishNotice: Notice? @Published var finishNotice: Notice?
var resumedFromTi2 = false @Published var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard self.wizard = wizard
+2 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// `#savePresetDialog` save the live Stage 1/2 form as a custom /// `#savePresetDialog` save the live Stage 1/2 form as a custom
/// preset (issue #11). Names/descriptions render via `Text` only (#114). /// preset (issue #11). Names/descriptions render via `Text` only (#114).
struct SavePresetDialog: View { struct SavePresetDialog: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
/// `#managePresetsDialog` list, delete (custom only), import, export. /// `#managePresetsDialog` list, delete (custom only), import, export.
struct ManagePresetsDialog: View { struct ManagePresetsDialog: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -1,23 +1,22 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85). /// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor @MainActor
@Observable final class PrintSessionViewModel: ObservableObject {
final class PrintSessionViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
var printers: [Printer] = [] @Published var printers: [Printer] = []
var selectedPrinter = "" @Published var selectedPrinter = ""
var printerCaps = PrinterCapabilities() @Published var printerCaps = PrinterCapabilities()
var selectedTray: Int? @Published var selectedTray: Int?
var selectedMediaType: String? @Published var selectedMediaType: String?
var printOrientation = "portrait" @Published var printOrientation = "portrait"
var capturedCupsOptions: [String: String] = [:] @Published var capturedCupsOptions: [String: String] = [:]
var printNotice: Notice? @Published var printNotice: Notice?
var isPrinting = false @Published var isPrinting = false
private var printTask: Task<Void, Never>? private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -111,7 +110,8 @@ final class PrintSessionViewModel {
isPrinting = true isPrinting = true
let task = Task { @MainActor [weak self] in let task = Task { @MainActor [weak self] in
guard let self else { return } 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 var printed = 0
for page in result.pages { for page in result.pages {
do { do {
@@ -124,6 +124,7 @@ final class PrintSessionViewModel {
+ error.localizedDescription + error.localizedDescription
) )
isPrinting = false isPrinting = false
self.printTask = nil
return return
} }
} }
@@ -133,6 +134,7 @@ final class PrintSessionViewModel {
autoHideAfter: nil autoHideAfter: nil
) )
isPrinting = false isPrinting = false
self.printTask = nil
} }
printTask = task printTask = task
} }
@@ -142,7 +144,6 @@ final class PrintSessionViewModel {
isPrinting = true isPrinting = true
let task = Task { @MainActor [weak self] in let task = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.printTask = nil }
do { do {
try await spool(page, index: page.index, pageSize: pageSize) try await spool(page, index: page.index, pageSize: pageSize)
printNotice = Notice( printNotice = Notice(
@@ -157,6 +158,7 @@ final class PrintSessionViewModel {
) )
} }
isPrinting = false isPrinting = false
self.printTask = nil
} }
printTask = task printTask = task
} }
+30 -31
View File
@@ -1,12 +1,11 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install. /// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor @MainActor
@Observable final class ProfileWorkflowViewModel: ObservableObject {
final class ProfileWorkflowViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@@ -14,50 +13,50 @@ final class ProfileWorkflowViewModel {
// MARK: - Stage 4 form // MARK: - Stage 4 form
var algorithm: String = "l" // l | x | X | m @Published var algorithm: String = "l" // l | x | X | m
var quality: String = "m" // l | m | h | u @Published var quality: String = "m" // l | m | h | u
var intent: String = "" // usually empty at Stage 4 @Published var intent: String = "" // usually empty at Stage 4
var fwaSelection: ColprofFwaSelection = .none @Published var fwaSelection: ColprofFwaSelection = .none
var fwaCustomPath: String = "" @Published var fwaCustomPath: String = ""
var illuminant: String = "" @Published var illuminant: String = ""
var observer: String = "" @Published var observer: String = ""
var inputViewingCond: String = "" @Published var inputViewingCond: String = ""
var outputViewingCond: String = "" @Published var outputViewingCond: String = ""
var profileDescription: String = "" @Published var profileDescription: String = ""
var copyright: String = "" @Published var copyright: String = ""
// MARK: - Run state // MARK: - Run state
var isColprofRunning = false @Published var isColprofRunning = false
var colprofLog: [String] = [] @Published var colprofLog: [String] = []
var colprofProgress: String? @Published var colprofProgress: String?
var createdProfileURL: URL? @Published var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). /// 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) // MARK: - Stage 4/5 calibration (issue #24)
var applyCalibration = false @Published var applyCalibration = false
var calibrationFile: String = "" @Published var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25) // MARK: - Stage 5 verification (issue #25)
var profcheckReport: ProfcheckReport? @Published var profcheckReport: ProfcheckReport?
var profcheckWarning: String? @Published var profcheckWarning: String?
var isProfcheckRunning = false @Published var isProfcheckRunning = false
// MARK: - History / drift (issue #26) // MARK: - History / drift (issue #26)
var verificationHistory: [VerificationRecord] = [] @Published var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil @Published var driftPrinterFilter: String? = nil
var driftAlert: String? @Published var driftAlert: String?
var isHistoryStoreError: String? @Published var isHistoryStoreError: String?
// MARK: - Install (issue #27) // MARK: - Install (issue #27)
var installResult: InstallProfileResult? @Published var installResult: InstallProfileResult?
var showingInstallCollision = false @Published var showingInstallCollision = false
var installCollisionMessage: String = "" @Published var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions? var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
+11 -5
View File
@@ -5,12 +5,18 @@ import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification /// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell). /// banner pinned to the top (docs/21 §Shell).
struct RootView: View { 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 showingSettings = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingAllHelp = 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 { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -64,11 +70,11 @@ struct RootView: View {
} }
/// Content for the active wizard stage. Isolated into its own view so that /// 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. /// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View { private struct WizardStageContent: View {
@Bindable var model: WizardViewModel @ObservedObject var model: WizardViewModel
var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
switch model.stage { switch model.stage {
+1 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with /// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
/// the full v1 field set; ΔE validation shows inline under the fields. /// the full v1 field set; ΔE validation shows inline under the fields.
struct SettingsView: View { struct SettingsView: View {
@State var model = SettingsViewModel() @StateObject var model = SettingsViewModel()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
private static let instruments: [(code: String, label: String)] = [ private static let instruments: [(code: String, label: String)] = [
@@ -143,7 +143,6 @@ struct SettingsView: View {
} }
} }
} }
.formStyle(.grouped)
Divider() Divider()
+5 -5
View File
@@ -1,4 +1,5 @@
import AppKit import AppKit
import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
@@ -6,12 +7,11 @@ import ICCeryCore
/// validation; the log level is applied live via `LogSink` (#158) and a /// validation; the log level is applied live via `LogSink` (#158) and a
/// `settingsDidChange` notification fans out to #20. /// `settingsDidChange` notification fans out to #20.
@MainActor @MainActor
@Observable final class SettingsViewModel: ObservableObject {
final class SettingsViewModel {
var settings: AppSettings @Published var settings: AppSettings
var validationErrors: [String] = [] @Published var validationErrors: [String] = []
var savedFlash = false @Published var savedFlash = false
private let store: SettingsStore private let store: SettingsStore
private let sink: LogSink private let sink: LogSink
+18 -2
View File
@@ -4,12 +4,28 @@ import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset /// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 15 stepper. /// select, Calibrate Printer + status chip, and the 15 stepper.
struct SidebarView: View { 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 onOpenSettings: () -> Void
var onOpenAbout: () -> Void var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool @Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard } init(
workflow: TargetWorkflowViewModel,
onOpenSettings: @escaping () -> Void,
onOpenAbout: @escaping () -> Void,
showingAllHelp: Binding<Bool>
) {
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 { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
+1 -1
View File
@@ -5,7 +5,7 @@ import ICCeryCore
/// docs/08). All documented element ids are wired as accessibility /// docs/08). All documented element ids are wired as accessibility
/// identifiers so the UI-test contract stays stable. /// identifiers so the UI-test contract stays stable.
struct Stage1View: View { struct Stage1View: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
ScrollView { ScrollView {
+36 -6
View File
@@ -5,7 +5,19 @@ import ICCeryCore
/// issues #9/#10, docs/09). Print controls are visible but inert /// issues #9/#10, docs/09). Print controls are visible but inert
/// real spooling lands in M3. /// real spooling lands in M3.
struct Stage2View: View { 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<Void, Never>?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -240,7 +252,7 @@ struct Stage2View: View {
} }
.frame(maxWidth: 320) .frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect") .accessibilityIdentifier("printerSelect")
.onChange(of: workflow.print.selectedPrinter) { _, _ in .onChange(of: workflow.print.selectedPrinter) { _ in
workflow.print.selectedTray = nil workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() } Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
@@ -328,9 +340,18 @@ struct Stage2View: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel") .accessibilityIdentifier("rawPrintPanel")
.task(id: workflow.printtargResult?.pages.count) { .onAppear { schedulePrinterRefresh() }
// Auto-enumerate once a manifest exists and whenever it .onChange(of: workflow.printtargResult?.pages.count) { _ in
// changes (e.g. resume from .ti2). 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 { if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.print.refreshPrinters() workflow.print.refreshPrinters()
} }
@@ -341,7 +362,16 @@ struct Stage2View: View {
/// One gallery cell: PNG preview + per-page Print button. /// One gallery cell: PNG preview + per-page Print button.
private struct GalleryPageView: View { private struct GalleryPageView: View {
let page: GalleryPage 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 { var body: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
+9 -1
View File
@@ -4,7 +4,15 @@ import ICCeryCore
/// Stage 3 measurement, live swatches, and multi-pass averaging. /// Stage 3 measurement, live swatches, and multi-pass averaging.
struct Stage3View: View { 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+13 -1
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 4 build an ICC/ICM profile from the canonical `.ti3`. /// Stage 4 build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View { 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -130,6 +138,9 @@ struct Stage4View: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCopyright") .accessibilityIdentifier("colprofCopyright")
// 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) Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration") .accessibilityIdentifier("colprofApplyCalibration")
@@ -143,6 +154,7 @@ struct Stage4View: View {
} }
} }
} }
}
.padding(16) .padding(16)
.background(Theme.panel) .background(Theme.panel)
} }
+9 -1
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 5 verify the generated profile, track drift, and install. /// Stage 5 verify the generated profile, track drift, and install.
struct Stage5View: View { 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+58 -59
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset /// 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 /// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back. /// coalesced log batches and completion hop back.
@MainActor @MainActor
@Observable final class TargetWorkflowViewModel: ObservableObject {
final class TargetWorkflowViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@@ -19,95 +18,95 @@ final class TargetWorkflowViewModel {
// MARK: - Stage 1 form (targen) // MARK: - Stage 1 form (targen)
var colourSpace: ColourSpace = .rgb { @Published var colourSpace: ColourSpace = .rgb {
didSet { didSet {
guard colourSpace != oldValue else { return } guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08). // CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4 blackPatches = colourSpace == .cmyk ? 0 : 4
} }
} }
var patchPreset: PatchCountPreset = .standard800 @Published var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` used when `patchPreset == .custom`. /// `#patchCountCustom` used when `patchPreset == .custom`.
var customPatchCount = 2500 @Published var customPatchCount = 2500
var whitePatches = 4 @Published var whitePatches = 4
var blackPatches = 4 @Published var blackPatches = 4
// Advanced each optional flag is enabled + value, so an untouched // Advanced each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in). // control emits nothing (#advanced fields are opt-in).
var greyStepsEnabled = false @Published var greyStepsEnabled = false
var greySteps = 5 @Published var greySteps = 5
var singleChannelEnabled = false @Published var singleChannelEnabled = false
var singleChannelSteps = 5 @Published var singleChannelSteps = 5
var neutralStepsEnabled = false @Published var neutralStepsEnabled = false
var neutralSteps = 3 @Published var neutralSteps = 3
var neutralConcEnabled = false @Published var neutralConcEnabled = false
var neutralConcentration = 0.50 @Published var neutralConcentration = 0.50
var preconditioningProfile: String? @Published var preconditioningProfile: String?
var highQuality = false @Published var highQuality = false
var adaptationEnabled = false @Published var adaptationEnabled = false
var adaptation = 0.10 @Published var adaptation = 0.10
var algorithm: FullSpreadAlgorithm = .ofps @Published var algorithm: FullSpreadAlgorithm = .ofps
var inkLimitEnabled = false @Published var inkLimitEnabled = false
var totalInkLimit = 320 @Published var totalInkLimit = 320
var darkEmphasisEnabled = false @Published var darkEmphasisEnabled = false
var darkEmphasis = 1.0 @Published var darkEmphasis = 1.0
var devicePowerEnabled = false @Published var devicePowerEnabled = false
var devicePower = 1.0 @Published var devicePower = 1.0
/// `#targetBasename` no placeholder is ever invented (#60). /// `#targetBasename` no placeholder is ever invented (#60).
var targetBasename = "" @Published var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd. /// `#selectedPathDisplay` / resolved cwd.
var targetDirectory: URL? @Published var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg) // MARK: - Stage 2 form (printtarg)
var instrument: PrintInstrument = .i1 @Published var instrument: PrintInstrument = .i1
var pageSize: PageSize = .a4 @Published var pageSize: PageSize = .a4
var customPageW = 210.0 @Published var customPageW = 210.0
var customPageH = 297.0 @Published var customPageH = 297.0
var bitDepth: TiffBitDepth = .eight @Published var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` two-way bound; presets can change it (150-DPI draft /// `#tiffDpi` two-way bound; presets can change it (150-DPI draft
/// regression must be visible here). /// regression must be visible here).
var tiffDpi = 300 @Published var tiffDpi = 300
var layoutOrder: LayoutOrder = .deterministic @Published var layoutOrder: LayoutOrder = .deterministic
var customSeed = 1 @Published var customSeed = 1
var labelIsCustom = false @Published var labelIsCustom = false
var customLabel = "" @Published var customLabel = ""
var metaPrinter = "" @Published var metaPrinter = ""
var metaInkSet = "" @Published var metaInkSet = ""
var metaDriverPaper = "" @Published var metaDriverPaper = ""
var metaActualPaper = "" @Published var metaActualPaper = ""
// MARK: - Run state // MARK: - Run state
var targenRunning = false @Published var targenRunning = false
var targenLog: [String] = [] @Published var targenLog: [String] = []
var printtargRunning = false @Published var printtargRunning = false
var printtargLog: [String] = [] @Published var printtargLog: [String] = []
var printtargResult: PrinttargResult? @Published var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on /// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data). /// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false @Published var resumedFromTi2 = false
// MARK: - Presets // MARK: - Presets
var presets: [ProfilingPreset] = [] @Published var presets: [ProfilingPreset] = []
var selectedPresetID = "none" @Published var selectedPresetID = "none"
var showingSavePreset = false @Published var showingSavePreset = false
var showingManagePresets = false @Published var showingManagePresets = false
var savePresetName = "" @Published var savePresetName = ""
var savePresetDesc = "" @Published var savePresetDesc = ""
/// Stage 3 measurement workflow, owned at the app level so it persists /// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes. /// 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 /// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can observe preset values. /// across stage switches and can observe preset values.
var profile: ProfileWorkflowViewModel @Published var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow. /// Stage 0 calibration workflow.
var calibration: CalibrationViewModel! @Published var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session. /// Stage 2 unmanaged print session.
var print: PrintSessionViewModel! @Published var print: PrintSessionViewModel!
init(environment: AppEnvironment = .live()) { init(environment: AppEnvironment = .live()) {
self.environment = environment self.environment = environment
+13 -14
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06). /// Wizard state machine + artefact gating (issue #4, docs/06).
@@ -10,47 +10,46 @@ import ICCeryCore
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` /// `wizard_state.json`; unlocks come from `ArtefactProbe.verify`
/// navigation is disk, not buttons. /// navigation is disk, not buttons.
@MainActor @MainActor
@Observable final class WizardViewModel: ObservableObject {
final class WizardViewModel {
// MARK: - wizardState fields (persisted) // MARK: - wizardState fields (persisted)
var stage: WizardStage { @Published var stage: WizardStage {
didSet { if stage != oldValue { persist() } } didSet { if stage != oldValue { persist() } }
} }
/// `wizardState.basename` empty until a real artefact names it (#60). /// `wizardState.basename` empty until a real artefact names it (#60).
var basename: String { @Published var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } } didSet { if basename != oldValue { refreshGating(); persist() } }
} }
/// `wizardState.cwd` resolved via `resolveSafeCwd` (#59). /// `wizardState.cwd` resolved via `resolveSafeCwd` (#59).
var workingDirectory: URL? { @Published var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } } didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
} }
var printerName: String? { @Published var printerName: String? {
didSet { if printerName != oldValue { persist() } } didSet { if printerName != oldValue { persist() } }
} }
var sessionMode: SessionMode { @Published var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } } didSet { if sessionMode != oldValue { persist() } }
} }
/// `profileBasename` may differ after a `.ti3` import (#94). /// `profileBasename` may differ after a `.ti3` import (#94).
var profileBasename: String? { @Published var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } } didSet { if profileBasename != oldValue { persist() } }
} }
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29). /// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
var calibrationOriginalBasename: String { @Published var calibrationOriginalBasename: String {
didSet { if calibrationOriginalBasename != oldValue { persist() } } didSet { if calibrationOriginalBasename != oldValue { persist() } }
} }
// MARK: - Ephemeral // MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`). /// Banner notice currently displayed (`#wizardNotification`).
var notice: Notice? @Published var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`. /// 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). /// 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. /// Optional `.gam` URL to show alongside the sRGB reference.
var gamutProfileURL: URL? @Published var gamutProfileURL: URL?
private let stateStore: WizardStateStore private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>? private var noticeDismissTask: Task<Void, Never>?
@@ -1,9 +1,8 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ArgyllRunner Calibration") final class ArgyllRunnerCalibrationTests: XCTestCase {
struct ArgyllRunnerCalibrationTests {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner { private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
return root return root
} }
@Test("Calibration targen produces CAL_*.ti1") func testCalibrationTargenProducesTi1() async throws {
func calibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let runner = makeRunner() let runner = makeRunner()
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
@@ -36,13 +34,12 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config) let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_demo.ti1") XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("Calibration targen from foo runs as process id targen_CAL_foo") func testCalibrationTargenProcessId() async throws {
func calibrationTargenProcessId() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let pm = ProcessManager() let pm = ProcessManager()
let runner = makeRunner(processManager: pm) let runner = makeRunner(processManager: pm)
@@ -65,13 +62,13 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config) let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_foo.ti1") XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
#expect(await sawExit.value) let sawExitEvent = await sawExit.value
XCTAssertTrue(sawExitEvent)
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("printcal captured run creates .cal") func testPrintcalProducesCal() async throws {
func printcalProducesCal() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let runner = makeRunner() let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal") let output = testRoot.appendingPathComponent("CAL_demo.cal")
@@ -83,13 +80,12 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runPrintcal(config: config) let url = try await runner.runPrintcal(config: config)
#expect(url.lastPathComponent == "CAL_demo.cal") XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("printcal failure throws toolFailed") func testPrintcalFailureThrows() async throws {
func printcalFailureThrows() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
defer { try? FileManager.default.removeItem(at: testRoot) } defer { try? FileManager.default.removeItem(at: testRoot) }
@@ -117,9 +113,11 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output outputURL: output
) )
await #expect(throws: ArgyllRunnerError.toolFailed( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
_ = try await runner.runPrintcal(config: config) _ = try await runner.runPrintcal(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
} }
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
final class LogHolder: @unchecked Sendable { final class LogHolder: @unchecked Sendable {
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
} }
} }
@Suite("ArgyllRunner colprof") final class ArgyllRunnerColprofTests: XCTestCase {
struct ArgyllRunnerColprofTests {
@Test("Mock colprof produces .icc") func testColprofProducesIcc() async throws {
func colprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() .deletingLastPathComponent()
.deletingLastPathComponent() .deletingLastPathComponent()
@@ -43,15 +41,14 @@ struct ArgyllRunnerColprofTests {
holder.append(batch) holder.append(batch)
} }
#expect(url.lastPathComponent == "testrun.icc") XCTAssertEqual(url.lastPathComponent, "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") }) XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("Failing colprof throws toolFailed with code and logs") func testColprofFailureThrowsToolFailed() async throws {
func colprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)") .appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -72,9 +69,11 @@ struct ArgyllRunnerColprofTests {
) )
let config = ColprofConfig(basename: "failrun", workingDirectory: dir) let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.toolFailed( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tool: "colprof", code: 4, logs: ["colprof broke"])) {
try await runner.runColprof(config: config) try await runner.runColprof(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"]))
} }
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// Focused contracts for the shared `runStreamingTool` loop (#79). /// Focused contracts for the shared `runStreamingTool` loop (#79).
@@ -7,8 +7,7 @@ import Testing
/// Every test uses a per-test temporary directory, unique basenames, /// Every test uses a per-test temporary directory, unique basenames,
/// and a fresh `ProcessManager` no shared UI fixture scripts and no /// and a fresh `ProcessManager` no shared UI fixture scripts and no
/// process-environment mutation. /// process-environment mutation.
@Suite("ArgyllRunner streaming loop contracts") final class ArgyllRunnerStreamingLoopTests: XCTestCase {
struct ArgyllRunnerStreamingLoopTests {
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -30,8 +29,7 @@ struct ArgyllRunnerStreamingLoopTests {
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)) binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
} }
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines") func testNonZeroExitThrowsToolFailed() async throws {
func nonZeroExitThrowsToolFailed() async throws {
let dir = try makeTempDir() let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """ try writeMock("targen", """
@@ -47,21 +45,20 @@ struct ArgyllRunnerStreamingLoopTests {
do { do {
_ = try await runner.runTargen(config: config) _ = try await runner.runTargen(config: config)
Issue.record("Expected toolFailed") XCTFail("Expected toolFailed")
} catch let error as ArgyllRunnerError { } catch let error as ArgyllRunnerError {
guard case .toolFailed(let tool, let code, let logs) = error else { guard case .toolFailed(let tool, let code, let logs) = error else {
Issue.record("Expected toolFailed, got \(error)") XCTFail("Expected toolFailed, got \(error)")
return return
} }
#expect(tool == "targen") XCTAssertEqual(tool, "targen")
#expect(code == 3) XCTAssertEqual(code, 3)
#expect(logs.contains("Generating patches...")) XCTAssertTrue(logs.contains("Generating patches..."))
#expect(logs.contains("targen: too few patches")) XCTAssertTrue(logs.contains("targen: too few patches"))
} }
} }
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path") func testZeroExitMissingArtefact() async throws {
func zeroExitMissingArtefact() async throws {
let dir = try makeTempDir() let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """ try writeMock("targen", """
@@ -75,13 +72,14 @@ struct ArgyllRunnerStreamingLoopTests {
colourSpace: .rgb, patchCount: 800, whitePatches: 4, colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "gone", workingDirectory: dir) blackPatches: 4, basename: "gone", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config) 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 testImmediateExitDeliversLine() async throws {
func immediateExitDeliversLine() async throws {
let dir = try makeTempDir() let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """ try writeMock("targen", """
@@ -101,13 +99,12 @@ struct ArgyllRunnerStreamingLoopTests {
let url = try await runner.runTargen(config: config) { batch in let url = try await runner.runTargen(config: config) { batch in
holder.append(batch) holder.append(batch)
} }
#expect(url.lastPathComponent == "quick.ti1") XCTAssertEqual(url.lastPathComponent, "quick.ti1")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("only line")) XCTAssertTrue(holder.lines.contains("only line"))
} }
@Test("colprof unterminated progress fragment reaches onLogBatch before exit") func testColprofPartialLineFlush() async throws {
func colprofPartialLineFlush() async throws {
let dir = try makeTempDir() let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
// The fragment is printed without a newline, then the mock sleeps // 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 let url = try await runner.runColprof(config: config) { batch in
holder.append(batch) holder.append(batch)
} }
#expect(url.lastPathComponent == "frag.icc") XCTAssertEqual(url.lastPathComponent, "frag.icc")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("Doing gamut mapping")) XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
} }
@Test("toolFailed maps each tool to its user-facing description", func testToolDescriptions() {
arguments: [ let cases: [(tool: String, expected: String)] = [
(tool: "chartread", expected: "Chartread failed: boom"), (tool: "chartread", expected: "Chartread failed: boom"),
(tool: "average", expected: "Averaging failed: boom"), (tool: "average", expected: "Averaging failed: boom"),
(tool: "colprof", expected: "Profile creation failed: boom"), (tool: "colprof", expected: "Profile creation failed: boom"),
@@ -143,19 +140,19 @@ struct ArgyllRunnerStreamingLoopTests {
(tool: "applycal", expected: "Apply calibration failed: boom"), (tool: "applycal", expected: "Apply calibration failed: boom"),
(tool: "iccgamut", expected: "Gamut extraction failed: boom"), (tool: "iccgamut", expected: "Gamut extraction failed: boom"),
(tool: "profcheck", expected: "Profile verification failed: boom"), (tool: "profcheck", expected: "Profile verification failed: boom"),
]) ]
func toolDescriptions(tool: String, expected: String) { for (tool, expected) in cases {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"]) let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
#expect(error.errorDescription == expected) XCTAssertEqual(error.errorDescription, expected)
}
} }
@Test("toolFailed falls back to a generic description for unmapped tools and empty logs") func testGenericFallbacks() {
func genericFallbacks() {
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"]) 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: []) let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
#expect(emptyLogs.errorDescription XCTAssertEqual(emptyLogs.errorDescription,
== "Profile creation failed: exited with code 2") "Profile creation failed: exited with code 2")
} }
} }
@@ -1,9 +1,8 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CalibrationStore") final class CalibrationStoreTests: XCTestCase {
struct CalibrationStoreTests {
private static let sampleCal = """ private static let sampleCal = """
CTI3 CTI3
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
END_DATA END_DATA
""" """
@Test("Loads metadata and curves from .cal") func testParseCal() async throws {
func parseCal() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal") .appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
try await store.load(url: url) try await store.load(url: url)
let data = await store.data let data = await store.data
#expect(data?.colorRep == "RGB") XCTAssertEqual(data?.colorRep, "RGB")
#expect(data?.descriptor == "Test printer") XCTAssertEqual(data?.descriptor, "Test printer")
#expect(data?.maxTac == 300) XCTAssertEqual(data?.maxTac, 300)
#expect(data?.curves.count == 3) XCTAssertEqual(data?.curves.count, 3)
let r = data?.curves.first { $0.channel == "R" } 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 testStaleCalibration() async throws {
func staleCalibration() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal") .appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
let store = CalibrationStore(staleDays: 0) let store = CalibrationStore(staleDays: 0)
try await store.load(url: url) try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other") let stale = await store.isStale(comparedTo: "Other")
#expect(stale == true) XCTAssertEqual(stale, true)
} }
@Test("Printer mismatch is flagged as stale") func testPrinterMismatch() async throws {
func printerMismatch() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal") .appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
try await store.load(url: url) try await store.load(url: url)
await store.setPrinterName("Printer A") await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B") let stale = await store.isStale(comparedTo: "Printer B")
#expect(stale == true) XCTAssertEqual(stale, true)
} }
} }
@@ -71,10 +71,14 @@ final class ColorSyncSuppressorTests: XCTestCase {
s.modeResolver = { name in s.modeResolver = { name in
if Self.missing.contains(name) { return nil } if Self.missing.contains(name) { return nil }
Self.currentSymbol = name Self.currentSymbol = name
// `Self` inside a @convention(c) closure is a dynamic-Self
// capture spell the (final) class name instead.
return { _, modeArg in return { _, modeArg in
Self.recorded.append((Self.currentSymbol, modeArg as String)) ColorSyncSuppressorTests.recorded.append(
if let ok = Self.succeeding, (ColorSyncSuppressorTests.currentSymbol, modeArg as String))
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 { if let ok = ColorSyncSuppressorTests.succeeding,
ColorSyncSuppressorTests.currentSymbol == ok.0,
(modeArg as String) == ok.1 {
return 0 return 0
} }
return 1 return 1
+14 -21
View File
@@ -1,26 +1,23 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("JSONFileStore") final class JSONFileStoreTests: XCTestCase {
struct JSONFileStoreTests {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json") .appendingPathComponent("json-store-\(UUID().uuidString).json")
} }
@Test("Missing file returns default") func testMissingFileDefaults() throws {
func missingFileDefaults() throws {
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
fileURL: tempURL(), fileURL: tempURL(),
corrupt: .throwCorrupt, corrupt: .throwCorrupt,
defaultValue: { .default } defaultValue: { .default }
) )
#expect(try store.load() == .default) XCTAssertEqual(try store.load(), .default)
} }
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes") func testCorruptDefaults() throws {
func corruptDefaults() throws {
let url = tempURL() let url = tempURL()
try "{ not json".write(to: url, atomically: true, encoding: .utf8) try "{ not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
@@ -28,13 +25,12 @@ struct JSONFileStoreTests {
corrupt: .replaceWithDefault, corrupt: .replaceWithDefault,
defaultValue: { .default } defaultValue: { .default }
) )
#expect(try store.load() == .default) XCTAssertEqual(try store.load(), .default)
let kept = try String(contentsOf: url, encoding: .utf8) 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 testCorruptThrows() throws {
func corruptThrows() throws {
let url = tempURL() let url = tempURL()
try "not json".write(to: url, atomically: true, encoding: .utf8) try "not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<[Int]>( let store = JSONFileStore<[Int]>(
@@ -42,15 +38,12 @@ struct JSONFileStoreTests {
corrupt: .throwCorrupt, corrupt: .throwCorrupt,
defaultValue: { [] } defaultValue: { [] }
) )
#expect(throws: DecodingError.self) { XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
_ = try store.load()
}
let kept = try String(contentsOf: url, encoding: .utf8) let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json") XCTAssertEqual(kept, "not json")
} }
@Test("Pretty sorted keys") func testPrettySorted() throws {
func prettySorted() throws {
let url = tempURL() let url = tempURL()
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
fileURL: url, fileURL: url,
@@ -59,8 +52,8 @@ struct JSONFileStoreTests {
) )
try store.save(.default) try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8) let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains("\n")) XCTAssertTrue(text.contains("\n"))
#expect(text.contains("\"delta_e_good_max\"")) XCTAssertTrue(text.contains("\"delta_e_good_max\""))
// Lexical key sorting: ascending order of top-level keys. // Lexical key sorting: ascending order of top-level keys.
let keys = [ let keys = [
"ask_before_overwrite_profile", "ask_before_overwrite_profile",
@@ -75,7 +68,7 @@ struct JSONFileStoreTests {
var lastIndex = text.startIndex var lastIndex = text.startIndex
for key in keys { for key in keys {
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else { guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
Issue.record("missing or out-of-order key \(key)") XCTFail("missing or out-of-order key \(key)")
return return
} }
lastIndex = range.upperBound lastIndex = range.upperBound
+163 -186
View File
@@ -1,116 +1,98 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfilingPreset") final class ProfilingPresetTests: XCTestCase {
struct ProfilingPresetTests {
@Test("snake_case keys round-trip through Codable") func testRoundTrip() throws {
func roundTrip() throws {
var p = PresetCatalog.highQualityCMYK var p = PresetCatalog.highQualityCMYK
p.colprofInputViewingCond = "D50_2" p.colprofInputViewingCond = "D50_2"
let data = try JSONEncoder().encode(p) let data = try JSONEncoder().encode(p)
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
#expect(decoded == p) XCTAssertEqual(decoded, p)
// Spot-check the wire format. // Spot-check the wire format.
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["colour_space"] as? String == "cmyk") XCTAssertEqual(obj["colour_space"] as? String, "cmyk")
#expect(obj["patch_count"] as? Int == 1500) XCTAssertEqual(obj["patch_count"] as? Int, 1500)
#expect(obj["total_ink_limit"] as? Int == 320) XCTAssertEqual(obj["total_ink_limit"] as? Int, 320)
#expect(obj["bit_depth"] as? Int == 16) XCTAssertEqual(obj["bit_depth"] as? Int, 16)
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2") XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2")
} }
@Test("Unknown keys ignored; missing required field fails") func testSchemaTolerance() throws {
func schemaTolerance() throws {
let json = """ let json = """
{"id":"x","name":"N","colour_space":"rgb","patch_count":10, {"id":"x","name":"N","colour_space":"rgb","patch_count":10,
"white_patches":1,"black_patches":1,"instrument":"i1", "white_patches":1,"black_patches":1,"instrument":"i1",
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42} "page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
""".data(using: .utf8)! """.data(using: .utf8)!
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json) let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
#expect(ok.id == "x") XCTAssertEqual(ok.id, "x")
let missing = """ let missing = """
{"id":"x","name":"N","colour_space":"rgb"} {"id":"x","name":"N","colour_space":"rgb"}
""".data(using: .utf8)! """.data(using: .utf8)!
#expect(throws: DecodingError.self) { XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
}
} }
@Test("Validation rejects bad colour space / dpi / bit depth") func testValidation() {
func validation() { XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
#expect(throws: ProfilingPreset.ValidationError.self) { XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated() 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) }
#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()
}
} }
} }
@Suite("PresetCatalog") final class PresetCatalogTests: XCTestCase {
struct PresetCatalogTests {
@Test("Four built-ins with the documented values") func testBuiltIns() {
func builtIns() { XCTAssertEqual(PresetCatalog.builtIns.count, 4)
#expect(PresetCatalog.builtIns.count == 4)
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) }) let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
let std = byID["preset-std-rgb"]! 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.pageSize == "A4" && std.bitDepth == 8
&& std.dpi == 300 && std.colprofQuality == "m" && std.dpi == 300 && std.colprofQuality == "m"
&& std.whitePatches == 4 && std.blackPatches == 4) && std.whitePatches == 4 && std.blackPatches == 4)
let hq = byID["preset-hq-cmyk"]! 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.pageSize == "A3" && hq.bitDepth == 16
&& hq.dpi == 300 && hq.colprofQuality == "h" && hq.dpi == 300 && hq.colprofQuality == "h"
&& hq.totalInkLimit == 320 && hq.blackPatches == 8) && hq.totalInkLimit == 320 && hq.blackPatches == 8)
let draft = byID["preset-draft-rgb"]! 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.pageSize == "A4" && draft.bitDepth == 8
&& draft.dpi == 150 && draft.colprofQuality == "l") && draft.dpi == 150 && draft.colprofQuality == "l")
let ultra = byID["preset-ultra-rgb"]! 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.pageSize == "A3" && ultra.bitDepth == 16
&& ultra.dpi == 300 && ultra.colprofQuality == "u" && ultra.dpi == 300 && ultra.colprofQuality == "u"
&& ultra.ofpsHighQuality == true && ultra.ofpsHighQuality == true
&& ultra.whitePatches == 6 && ultra.blackPatches == 6) && ultra.whitePatches == 6 && ultra.blackPatches == 6)
for p in PresetCatalog.builtIns { for p in PresetCatalog.builtIns {
#expect(p.instrument == "i1") XCTAssertEqual(p.instrument, "i1")
#expect(p.colprofFwa == "D50") XCTAssertEqual(p.colprofFwa, "D50")
#expect(p.randomSeed == 1) XCTAssertEqual(p.randomSeed, 1)
#expect(p.noRandomize == false) XCTAssertEqual(p.noRandomize, false)
#expect(p.colprofAlgorithm == "l") XCTAssertEqual(p.colprofAlgorithm, "l")
} }
} }
@Test("Custom presets overlay by id; built-ins are not deletable") func testOverlay() {
func overlay() {
let custom = ProfilingPreset( let custom = ProfilingPreset(
id: "preset-std-rgb", name: "Shadowed", patchCount: 42) id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
let all = PresetCatalog.all(custom: [custom]) let all = PresetCatalog.all(custom: [custom])
#expect(all.count == 4) XCTAssertEqual(all.count, 4)
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42) XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42)
#expect(PresetCatalog.isBuiltIn("preset-std-rgb")) XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb"))
#expect(!PresetCatalog.isBuiltIn("custom-1")) XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1"))
} }
} }
@Suite("PresetStore") final class PresetStoreTests: XCTestCase {
struct PresetStoreTests {
private func tempSettingsURL() throws -> URL { private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -119,58 +101,52 @@ struct PresetStoreTests {
return dir.appendingPathComponent("settings.json") return dir.appendingPathComponent("settings.json")
} }
@Test("CRUD + export/import round-trip") func testCrud() throws {
func crud() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150) var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
try store.saveCustom(p) try store.saveCustom(p)
#expect(store.customs().count == 1) XCTAssertEqual(store.customs().count, 1)
#expect(store.all().count == 5) XCTAssertEqual(store.all().count, 5)
p.name = "Renamed" p.name = "Renamed"
try store.saveCustom(p) try store.saveCustom(p)
#expect(store.customs().count == 1) XCTAssertEqual(store.customs().count, 1)
#expect(store.customs()[0].name == "Renamed") XCTAssertEqual(store.customs()[0].name, "Renamed")
let data = try store.export(p) let data = try store.export(p)
let imported = try store.import(data) let imported = try store.import(data)
#expect(imported.name == "Renamed") XCTAssertEqual(imported.name, "Renamed")
#expect(imported.dpi == 150) XCTAssertEqual(imported.dpi, 150)
#expect(try store.deleteCustom(id: "custom-x")) XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
#expect(store.customs().isEmpty) XCTAssertTrue(store.customs().isEmpty)
#expect(try !store.deleteCustom(id: "preset-std-rgb")) XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
} }
@Test("Import rewrites a built-in id to a fresh custom id") func testImportBuiltinCollision() throws {
func importBuiltinCollision() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.standardRGB) let data = try store.export(PresetCatalog.standardRGB)
let imported = try store.import(data) let imported = try store.import(data)
#expect(imported.id.hasPrefix("custom-")) XCTAssertTrue(imported.id.hasPrefix("custom-"))
#expect(!PresetCatalog.isBuiltIn(imported.id)) XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
} }
@Test("Built-ins are immutable through saveCustom") func testBuiltInImmutable() throws {
func builtInImmutable() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var shadowed = PresetCatalog.standardRGB var shadowed = PresetCatalog.standardRGB
shadowed.name = "Hacked" shadowed.name = "Hacked"
#expect(throws: PresetStore.PresetStoreError.self) { XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
try store.saveCustom(shadowed)
}
} }
} }
@Suite("AppSettings preset migration") final class PresetMigrationTests: XCTestCase {
struct PresetMigrationTests {
private func tempSettingsURL() throws -> URL { private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -179,8 +155,7 @@ struct PresetMigrationTests {
return dir.appendingPathComponent("settings.json") return dir.appendingPathComponent("settings.json")
} }
@Test("Legacy M1 custom_presets migrate to typed schema") func testLegacyMigration() throws {
func legacyMigration() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let legacy = """ let legacy = """
@@ -194,20 +169,19 @@ struct PresetMigrationTests {
try legacy.write(to: url) try legacy.write(to: url)
let settings = SettingsStore(fileURL: url).load() let settings = SettingsStore(fileURL: url).load()
#expect(settings.customPresets.count == 1) XCTAssertEqual(settings.customPresets.count, 1)
let p = settings.customPresets[0] let p = settings.customPresets[0]
#expect(p.name == "Old One") XCTAssertEqual(p.name, "Old One")
#expect(p.id.hasPrefix("custom-0-")) XCTAssertTrue(p.id.hasPrefix("custom-0-"))
#expect(p.colourSpace == "cmyk") XCTAssertEqual(p.colourSpace, "cmyk")
#expect(p.patchCount == 900) XCTAssertEqual(p.patchCount, 900)
#expect(p.dpi == 150) XCTAssertEqual(p.dpi, 150)
#expect(p.bitDepth == 16) XCTAssertEqual(p.bitDepth, 16)
#expect(p.instrument == "p3") XCTAssertEqual(p.instrument, "p3")
#expect(p.pageSize == "A3") XCTAssertEqual(p.pageSize, "A3")
} }
@Test("Typed presets load and re-save as the typed schema") func testTypedRoundTrip() throws {
func typedRoundTrip() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
@@ -215,50 +189,45 @@ struct PresetMigrationTests {
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)] s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
try store.save(s) try store.save(s)
let loaded = store.load() 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 testDraftDPI() throws {
func draftDPI() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.draftRGB) let data = try store.export(PresetCatalog.draftRGB)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] 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) let back = try store.import(data)
#expect(back.dpi == 150) XCTAssertEqual(back.dpi, 150)
} }
} }
@Suite("Preset mapping") final class PresetMappingTests: XCTestCase {
struct PresetMappingTests { func testDraftDpi() {
@Test("Draft 150 DPI maps into PrinttargConfig")
func draftDpi() {
let cfg = PrinttargConfig( let cfg = PrinttargConfig(
preset: PresetCatalog.draftRGB, preset: PresetCatalog.draftRGB,
basename: "t", basename: "t",
workingDirectory: nil, workingDirectory: nil,
calibrationFile: nil calibrationFile: nil
) )
#expect(cfg.dpi == 150) XCTAssertEqual(cfg.dpi, 150)
#expect(cfg.layoutOrder == .deterministic) XCTAssertEqual(cfg.layoutOrder, .deterministic)
} }
@Test("Nil optional targen fields stay nil") func testOptionalNil() {
func optionalNil() {
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800) let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(cfg.greySteps == nil) XCTAssertNil(cfg.greySteps)
#expect(cfg.singleChannelSteps == nil) XCTAssertNil(cfg.singleChannelSteps)
#expect(cfg.neutralSteps == nil) XCTAssertNil(cfg.neutralSteps)
#expect(cfg.totalInkLimit == nil) XCTAssertNil(cfg.totalInkLimit)
#expect(cfg.darkEmphasis == nil) XCTAssertNil(cfg.darkEmphasis)
#expect(cfg.devicePower == nil) XCTAssertNil(cfg.devicePower)
} }
@Test("Custom page and FWA survive a config round-trip") func testRoundTripConfigs() {
func roundTripConfigs() {
var preset = PresetCatalog.highQualityCMYK var preset = PresetCatalog.highQualityCMYK
preset.pageSize = "210x297" preset.pageSize = "210x297"
preset.colprofFwa = "D50" preset.colprofFwa = "D50"
@@ -268,9 +237,9 @@ struct PresetMappingTests {
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
) )
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil) let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
#expect(printtarg.pageSize == .custom) XCTAssertEqual(printtarg.pageSize, .custom)
#expect(printtarg.customPageWidth == 210) XCTAssertEqual(printtarg.customPageWidth, 210)
#expect(colprof.fwa == "D50") XCTAssertEqual(colprof.fwa, "D50")
let back = ProfilingPreset( let back = ProfilingPreset(
id: preset.id, id: preset.id,
name: preset.name, name: preset.name,
@@ -281,15 +250,14 @@ struct PresetMappingTests {
calibrationFile: preset.calibrationFile, calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration applyCalibration: preset.applyCalibration
) )
#expect(back.dpi == preset.dpi) XCTAssertEqual(back.dpi, preset.dpi)
#expect(back.colourSpace == "cmyk") XCTAssertEqual(back.colourSpace, "cmyk")
#expect(back.pageSize == "210x297") XCTAssertEqual(back.pageSize, "210x297")
#expect(back.colprofFwa == "D50") XCTAssertEqual(back.colprofFwa, "D50")
#expect(back.greySteps == nil) XCTAssertNil(back.greySteps)
} }
@Test("Full preset round-trips through all three configs with every field asserted") func testFullRoundTrip() {
func fullRoundTrip() {
let preset = ProfilingPreset( let preset = ProfilingPreset(
id: "custom-full", id: "custom-full",
name: "Full", name: "Full",
@@ -328,21 +296,21 @@ struct PresetMappingTests {
) )
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil) let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
#expect(targen.colourSpace == .cmyk) XCTAssertEqual(targen.colourSpace, .cmyk)
#expect(targen.patchCount == 1500) XCTAssertEqual(targen.patchCount, 1500)
#expect(targen.whitePatches == 6) XCTAssertEqual(targen.whitePatches, 6)
#expect(targen.blackPatches == 8) XCTAssertEqual(targen.blackPatches, 8)
#expect(targen.greySteps == 9) XCTAssertEqual(targen.greySteps, 9)
#expect(targen.singleChannelSteps == 7) XCTAssertEqual(targen.singleChannelSteps, 7)
#expect(targen.neutralSteps == 4) XCTAssertEqual(targen.neutralSteps, 4)
#expect(targen.neutralConcentration == 0.7) XCTAssertEqual(targen.neutralConcentration, 0.7)
#expect(targen.preconditioningProfile == "/tmp/pre.icm") XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm")
#expect(targen.ofpsHighQuality == true) XCTAssertEqual(targen.ofpsHighQuality, true)
#expect(targen.ofpsAdaptation == 0.2) XCTAssertEqual(targen.ofpsAdaptation, 0.2)
#expect(targen.fullSpreadAlgorithm == .uniformRandom) XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom)
#expect(targen.totalInkLimit == 280) XCTAssertEqual(targen.totalInkLimit, 280)
#expect(targen.darkEmphasis == 1.3) XCTAssertEqual(targen.darkEmphasis, 1.3)
#expect(targen.devicePower == 1.2) XCTAssertEqual(targen.devicePower, 1.2)
let printtarg = PrinttargConfig( let printtarg = PrinttargConfig(
preset: preset, preset: preset,
@@ -350,25 +318,25 @@ struct PresetMappingTests {
workingDirectory: nil, workingDirectory: nil,
calibrationFile: preset.calibrationFile calibrationFile: preset.calibrationFile
) )
#expect(printtarg.instrument == .p3) XCTAssertEqual(printtarg.instrument, .p3)
#expect(printtarg.pageSize == .custom) XCTAssertEqual(printtarg.pageSize, .custom)
#expect(printtarg.customPageWidth == 250) XCTAssertEqual(printtarg.customPageWidth, 250)
#expect(printtarg.customPageHeight == 300) XCTAssertEqual(printtarg.customPageHeight, 300)
#expect(printtarg.bitDepth == .sixteen) XCTAssertEqual(printtarg.bitDepth, .sixteen)
#expect(printtarg.dpi == 360) XCTAssertEqual(printtarg.dpi, 360)
#expect(printtarg.layoutOrder == .customSeed) XCTAssertEqual(printtarg.layoutOrder, .customSeed)
#expect(printtarg.customSeed == 42) XCTAssertEqual(printtarg.customSeed, 42)
#expect(printtarg.calibrationFile == "/tmp/a.cal") XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal")
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil) let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
#expect(colprof.algorithm == "x") XCTAssertEqual(colprof.algorithm, "x")
#expect(colprof.quality == "u") XCTAssertEqual(colprof.quality, "u")
#expect(colprof.intent == "p") XCTAssertEqual(colprof.intent, "p")
#expect(colprof.fwa == "D65") XCTAssertEqual(colprof.fwa, "D65")
#expect(colprof.illuminant == "D65") XCTAssertEqual(colprof.illuminant, "D65")
#expect(colprof.observer == "1931_2") XCTAssertEqual(colprof.observer, "1931_2")
#expect(colprof.inputViewingCond == "D50_2") XCTAssertEqual(colprof.inputViewingCond, "D50_2")
#expect(colprof.outputViewingCond == "D65_2") XCTAssertEqual(colprof.outputViewingCond, "D65_2")
let back = ProfilingPreset( let back = ProfilingPreset(
id: preset.id, id: preset.id,
@@ -380,11 +348,12 @@ struct PresetMappingTests {
calibrationFile: preset.calibrationFile, calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration applyCalibration: preset.applyCalibration
) )
#expect(back == preset) XCTAssertEqual(back, preset)
} }
@Test("Every full-spread algorithm round-trips", arguments: [ func testFullSpreadAlgorithms() {
("ofps", FullSpreadAlgorithm.ofps), let cases: [(String, FullSpreadAlgorithm)] = [
("ofps", .ofps),
("t", .target), ("t", .target),
("r", .random), ("r", .random),
("R", .uniformRandom), ("R", .uniformRandom),
@@ -392,16 +361,16 @@ struct PresetMappingTests {
("Q", .uniformQuasiRandom), ("Q", .uniformQuasiRandom),
("i", .invertedQuasiRandom), ("i", .invertedQuasiRandom),
("I", .invertedUniformQuasiRandom) ("I", .invertedUniformQuasiRandom)
]) ]
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) { for (value, expected) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.fullSpreadAlgorithm = value preset.fullSpreadAlgorithm = value
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
if expected == .ofps { if expected == .ofps {
// ofps is the default no flag emitted, stored value is nil. // ofps is the default no flag emitted, stored value is nil.
#expect(cfg.fullSpreadAlgorithm == nil) XCTAssertNil(cfg.fullSpreadAlgorithm)
} else { } else {
#expect(cfg.fullSpreadAlgorithm == expected) XCTAssertEqual(cfg.fullSpreadAlgorithm, expected)
} }
let back = ProfilingPreset( let back = ProfilingPreset(
id: "x", name: "n", description: "", id: "x", name: "n", description: "",
@@ -414,30 +383,31 @@ struct PresetMappingTests {
calibrationFile: nil, calibrationFile: nil,
applyCalibration: nil applyCalibration: nil
) )
#expect(back.fullSpreadAlgorithm == value) XCTAssertEqual(back.fullSpreadAlgorithm, value)
}
} }
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil") func testOfpsHighQualityFalse() {
func ofpsHighQualityFalse() {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.ofpsHighQuality = false preset.ofpsHighQuality = false
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(cfg.ofpsHighQuality == false) XCTAssertEqual(cfg.ofpsHighQuality, false)
preset.ofpsHighQuality = nil preset.ofpsHighQuality = nil
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(nilCfg.ofpsHighQuality == nil) XCTAssertNil(nilCfg.ofpsHighQuality)
} }
@Test("noRandomize/seed layout mapping rules", arguments: [ func testLayoutMapping() {
(true, nil, LayoutOrder.raster, 1), let cases: [(Bool?, Int?, LayoutOrder, Int)] = [
(true, nil, .raster, 1),
(true, 7, .raster, 7), (true, 7, .raster, 7),
(false, nil, .deterministic, 1), (false, nil, .deterministic, 1),
(false, 1, .deterministic, 1), (false, 1, .deterministic, 1),
(nil, 1, .deterministic, 1), (nil, 1, .deterministic, 1),
(false, 5, .customSeed, 5) (false, 5, .customSeed, 5)
] as [(Bool?, Int?, LayoutOrder, Int)]) ]
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) { for (noRandomize, seed, layout, expectedSeed) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.noRandomize = noRandomize preset.noRandomize = noRandomize
preset.randomSeed = seed preset.randomSeed = seed
@@ -445,31 +415,35 @@ struct PresetMappingTests {
preset: preset, basename: "t", preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil workingDirectory: nil, calibrationFile: nil
) )
#expect(cfg.layoutOrder == layout) XCTAssertEqual(cfg.layoutOrder, layout)
#expect(cfg.customSeed == expectedSeed) XCTAssertEqual(cfg.customSeed, expectedSeed)
}
} }
@Test("Custom page fallback matrix", arguments: [ func testCustomPageFallback() {
("250x300", PageSize.custom, 250.0, 300.0), let cases: [(String, PageSize, Double, Double)] = [
("250x300", .custom, 250.0, 300.0),
("50x50", .custom, 50.0, 50.0), ("50x50", .custom, 50.0, 50.0),
("foo", .a4, 210.0, 297.0), ("foo", .a4, 210.0, 297.0),
("30x40", .a4, 210.0, 297.0), ("30x40", .a4, 210.0, 297.0),
("210x", .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) { for (raw, page, w, h) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.pageSize = raw preset.pageSize = raw
let cfg = PrinttargConfig( let cfg = PrinttargConfig(
preset: preset, basename: "t", preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil workingDirectory: nil, calibrationFile: nil
) )
#expect(cfg.pageSize == page) XCTAssertEqual(cfg.pageSize, page)
#expect(cfg.customPageWidth == w) XCTAssertEqual(cfg.customPageWidth, w)
#expect(cfg.customPageHeight == h) XCTAssertEqual(cfg.customPageHeight, h)
}
} }
@Test("FWA preset value → selection matrix", arguments: [ func testFwaToSelection() {
(nil, ColprofFwaSelection.none), let cases: [(String?, ColprofFwaSelection)] = [
(nil, .none),
("none", .none), ("none", .none),
("NONE", .none), ("NONE", .none),
("", .empty), ("", .empty),
@@ -478,19 +452,22 @@ struct PresetMappingTests {
("D65", .D65), ("D65", .D65),
("d65", .D65), ("d65", .D65),
("/tmp/fwa.sp", .custom) ("/tmp/fwa.sp", .custom)
] as [(String?, ColprofFwaSelection)]) ]
func fwaToSelection(raw: String?, expected: ColprofFwaSelection) { for (raw, expected) in cases {
#expect(ColprofFwaSelection(presetValue: raw) == expected) XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected)
}
} }
@Test("FWA selection → preset value matrix", arguments: [ func testFwaToPresetValue() {
(ColprofFwaSelection.none, nil), let cases: [(ColprofFwaSelection, String?)] = [
(.none, nil),
(.empty, ""), (.empty, ""),
(.D50, "D50"), (.D50, "D50"),
(.D65, "D65"), (.D65, "D65"),
(.custom, "/tmp/fwa.sp") (.custom, "/tmp/fwa.sp")
] as [(ColprofFwaSelection, String?)]) ]
func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) { for (selection, expected) in cases {
#expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected) XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected)
}
} }
} }
@@ -1,21 +1,19 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #82 preset application through the live view models, under an /// Issue #82 preset application through the live view models, under an
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager). /// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
@Suite("PresetViewModelMapping")
@MainActor @MainActor
struct PresetViewModelMappingTests { final class PresetViewModelMappingTests: XCTestCase {
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) { private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
let env = try TestAppEnvironment.make() let env = try TestAppEnvironment.make()
return (env, TargetWorkflowViewModel(environment: env.environment)) return (env, TargetWorkflowViewModel(environment: env.environment))
} }
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path") func testNilFwaClearsCustomPath() throws {
func nilFwaClearsCustomPath() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -24,18 +22,17 @@ struct PresetViewModelMappingTests {
colprofFwa: "/tmp/fwa.sp" colprofFwa: "/tmp/fwa.sp"
) )
vm.applyPreset(customPreset) vm.applyPreset(customPreset)
#expect(vm.profile.fwaSelection == .custom) XCTAssertEqual(vm.profile.fwaSelection, .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp") XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp")
customPreset.colprofFwa = nil customPreset.colprofFwa = nil
vm.applyPreset(customPreset) vm.applyPreset(customPreset)
#expect(vm.profile.fwaSelection == .none) XCTAssertEqual(vm.profile.fwaSelection, .none)
#expect(vm.profile.fwaCustomPath == "") XCTAssertEqual(vm.profile.fwaCustomPath, "")
#expect(vm.profile.fwaValue == nil) XCTAssertNil(vm.profile.fwaValue)
} }
@Test("Custom FWA preset path survives the round-trip to colprof_fwa") func testCustomFwaRoundTrip() throws {
func customFwaRoundTrip() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -44,13 +41,12 @@ struct PresetViewModelMappingTests {
colprofFwa: "/tmp/other.sp" colprofFwa: "/tmp/other.sp"
) )
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(vm.profile.fwaSelection == .custom) XCTAssertEqual(vm.profile.fwaSelection, .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp") XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp")
#expect(vm.profile.fwaValue == "/tmp/other.sp") XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp")
} }
@Test("Preset calibration reaches Stage 2 instead of stale live state") func testPresetCalibrationReachesStage2() throws {
func presetCalibrationReachesStage2() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -65,13 +61,12 @@ struct PresetViewModelMappingTests {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(vm.profile.applyCalibration) XCTAssertTrue(vm.profile.applyCalibration)
#expect(vm.profile.calibrationFile == "/tmp/preset.cal") XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal")
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal") XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal")
} }
@Test("Preset with calibration disabled clears Stage 2 calibration") func testDisabledCalibrationClearsStage2() throws {
func disabledCalibrationClearsStage2() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -85,12 +80,11 @@ struct PresetViewModelMappingTests {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(!vm.profile.applyCalibration) XCTAssertFalse(vm.profile.applyCalibration)
#expect(vm.buildPrinttargConfig().calibrationFile == nil) XCTAssertNil(vm.buildPrinttargConfig().calibrationFile)
} }
@Test("Preset Stage 1/2 form fields apply to the live form") func testFormFieldsApply() throws {
func formFieldsApply() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -106,22 +100,22 @@ struct PresetViewModelMappingTests {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(vm.colourSpace == .cmyk) XCTAssertEqual(vm.colourSpace, .cmyk)
#expect(vm.effectivePatchCount == 1500) XCTAssertEqual(vm.effectivePatchCount, 1500)
#expect(vm.whitePatches == 6) XCTAssertEqual(vm.whitePatches, 6)
#expect(vm.blackPatches == 8) XCTAssertEqual(vm.blackPatches, 8)
#expect(vm.greyStepsEnabled && vm.greySteps == 9) XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9)
#expect(vm.algorithm == .random) XCTAssertEqual(vm.algorithm, .random)
#expect(vm.tiffDpi == 150) XCTAssertEqual(vm.tiffDpi, 150)
#expect(vm.pageSize == .custom) XCTAssertEqual(vm.pageSize, .custom)
#expect(vm.customPageW == 250 && vm.customPageH == 300) XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300)
#expect(vm.selectedPresetID == "c-form") XCTAssertEqual(vm.selectedPresetID, "c-form")
// Disabled advanced controls stay nil in the snapshot, not // Disabled advanced controls stay nil in the snapshot, not
// numeric sentinels. // numeric sentinels.
preset.greySteps = nil preset.greySteps = nil
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(!vm.greyStepsEnabled) XCTAssertFalse(vm.greyStepsEnabled)
#expect(vm.buildTargenConfig().greySteps == nil) XCTAssertNil(vm.buildTargenConfig().greySteps)
} }
} }
+13 -17
View File
@@ -1,13 +1,12 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result). /// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the /// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on. /// `UITestHooks` seam the UI tests rely on.
@Suite("PrintPanelStub") final class PrintPanelStubTests: XCTestCase {
struct PrintPanelStubTests {
private func withEnv( private func withEnv(
_ vars: [String: String?], _ vars: [String: String?],
@@ -28,19 +27,17 @@ struct PrintPanelStubTests {
try body() try body()
} }
@Test("Cancel returns nil — not an error") func testCancelIsNil() throws {
func cancelIsNil() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel", "ICCERY_TEST_PRINT_PANEL": "cancel",
]) { ]) {
#expect(UITestHooks.printPanelStubbed) XCTAssertTrue(UITestHooks.printPanelStubbed)
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil) XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
} }
} }
@Test("OK returns captured options + selected printer") func testOkResult() throws {
func okResult() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok", "ICCERY_TEST_PRINT_PANEL": "ok",
@@ -48,15 +45,14 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue", "ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "q") let result = UITestHooks.printPanelResult(forQueue: "q")
#expect(result?.selectedPrinter == "Other_Queue") XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear") XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
#expect(result?.options.mediaType == "Photo") XCTAssertEqual(result?.options.mediaType, "Photo")
#expect(result?.options.ppdUncorrectedPassthrough == true) XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
} }
} }
@Test("OK defaults selected printer to the opened queue") func testOkDefaultsPrinter() throws {
func okDefaultsPrinter() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok", "ICCERY_TEST_PRINT_PANEL": "ok",
@@ -64,8 +60,8 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": nil, "ICCERY_TEST_PANEL_PRINTER": nil,
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue") let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
#expect(result?.selectedPrinter == "My_Queue") XCTAssertEqual(result?.selectedPrinter, "My_Queue")
#expect(result?.options.cupsOptions == nil) XCTAssertNil(result?.options.cupsOptions)
} }
} }
} }
+18 -23
View File
@@ -1,4 +1,3 @@
import Testing
import XCTest import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -257,8 +256,7 @@ final class PrinttargManifestTests: XCTestCase {
} }
} }
@Suite("ArgyllRunner Printtarg") final class ArgyllRunnerPrinttargTests: XCTestCase {
struct ArgyllRunnerPrinttargTests {
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL { private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -310,8 +308,7 @@ struct ArgyllRunnerPrinttargTests {
try Data(bytes).write(to: url) try Data(bytes).write(to: url)
} }
@Test("Successful printtarg emits .ti2 + manifest + PNG previews") func testSuccess() async throws {
func success() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -330,18 +327,17 @@ struct ArgyllRunnerPrinttargTests {
processManager: ProcessManager(), binaryResolver: resolver) processManager: ProcessManager(), binaryResolver: resolver)
let config = PrinttargConfig(basename: "pt", workingDirectory: dir) let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
let result = try await runner.runPrinttarg(config: config) let result = try await runner.runPrinttarg(config: config)
#expect(result.ti2URL.lastPathComponent == "pt.ti2") XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2")
#expect(result.manifest.pages.count == 1) XCTAssertEqual(result.manifest.pages.count, 1)
#expect(result.pages.count == 1) XCTAssertEqual(result.pages.count, 1)
let png = result.pages[0].previewPNG let png = result.pages[0].previewPNG
#expect(png != nil) XCTAssertNotNil(png)
if let 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 testFailure() async throws {
func failure() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
echo "oops" >&2 echo "oops" >&2
@@ -351,15 +347,16 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.toolFailed( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tool: "printtarg", code: 3, logs: ["oops"])) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) 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 testNoManifest() async throws {
func noManifest() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -372,14 +369,13 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
} }
@Test("Exit 0 without .ti2 → missingArtefact") func testNoTi2() async throws {
func noTi2() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
@@ -389,14 +385,13 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
} }
@Test("Deterministic config produces byte-identical .ti2") func testDeterminism() async throws {
func determinism() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -416,6 +411,6 @@ struct ArgyllRunnerPrinttargTests {
config: PrinttargConfig(basename: "b", workingDirectory: dir)) config: PrinttargConfig(basename: "b", workingDirectory: dir))
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2")) let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2")) let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
#expect(d1 == d2) XCTAssertEqual(d1, d2)
} }
} }
+104 -85
View File
@@ -1,12 +1,11 @@
import Testing
import XCTest import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Helpers shared across ProcessManager tests. Fixture binaries are shell /// Helpers shared across ProcessManager tests. Fixture binaries are shell
/// scripts written to a temp dir no resource bundling required. /// scripts written to a temp dir no resource bundling required.
@Suite("ProcessManager", .serialized) /// XCTest executes test methods serially by default.
struct ProcessManagerTests { final class ProcessManagerTests: XCTestCase {
// MARK: - Fixture plumbing // MARK: - Fixture plumbing
@@ -44,7 +43,7 @@ struct ProcessManagerTests {
if box.finish() { cont.resume(returning: box.events) } if box.finish() { cont.resume(returning: box.events) }
} }
Task { 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) } if box.finish() { cont.resume(returning: box.events) }
} }
} }
@@ -85,7 +84,7 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if exitCount(in: box) > 0 { return true } if exitCount(in: box) > 0 { return true }
try? await Task.sleep(for: .milliseconds(10)) try? await Task.sleep(nanoseconds: 10_000_000)
} }
return false return false
} }
@@ -94,7 +93,7 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true } 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 return false
} }
@@ -107,14 +106,14 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if await manager.isRunning(id) { return true } if await manager.isRunning(id) { return true }
try? await Task.sleep(for: .milliseconds(10)) try? await Task.sleep(nanoseconds: 10_000_000)
} }
return false return false
} }
// MARK: - Tests // MARK: - Tests
@Test func streamsStdoutAndEmitsExit() async throws { func testStreamsStdoutAndEmitsExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n") let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
async let events = collect(pm, id: "t1") async let events = collect(pm, id: "t1")
@@ -123,21 +122,21 @@ struct ProcessManagerTests {
let lines = evs.compactMap { e -> String? in let lines = evs.compactMap { e -> String? in
if case .stdout(_, let l) = e { return l }; return nil if case .stdout(_, let l) = e { return l }; return nil
} }
#expect(lines == ["hello", "world"]) XCTAssertEqual(lines, ["hello", "world"])
#expect(evs.contains(.exit(id: "t1", code: 0))) XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0)))
} }
@Test func routesStderrSeparately() async throws { func testRoutesStderrSeparately() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n") let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
async let evs = collect(pm, id: "t2") async let evs = collect(pm, id: "t2")
try await pm.runStreaming(id: "t2", binary: bin, arguments: []) try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
let events = await evs let events = await evs
#expect(events.contains(.stdout(id: "t2", line: "out"))) XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out")))
#expect(events.contains(.stderr(id: "t2", line: "oops"))) XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops")))
} }
@Test func stripsRowColorsJSONPrefix() async throws { func testStripsRowColorsJSONPrefix() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"rows.sh", "rows.sh",
@@ -150,21 +149,22 @@ struct ProcessManagerTests {
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
#expect(rows == ["{\"row\":1}"]) XCTAssertEqual(rows, ["{\"row\":1}"])
#expect(events.contains(.stdout(id: "t3", line: "plain"))) XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain")))
// Prefixed lines must not leak into stdout. // 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 pm = ProcessManager()
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n") let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
async let evs = collect(pm, id: "t4") async let evs = collect(pm, id: "t4")
try await pm.runStreaming(id: "t4", binary: bin, arguments: []) 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() let pm = ProcessManager()
// Read two lines then exit naturally a killed sh would lose its // Read two lines then exit naturally a killed sh would lose its
// buffered stdio output, which is exactly the chartread pattern. // buffered stdio output, which is exactly the chartread pattern.
@@ -177,21 +177,23 @@ struct ProcessManagerTests {
try await pm.sendStdin(id: "t5", text: " \n") try await pm.sendStdin(id: "t5", text: " \n")
try await pm.sendStdin(id: "t5", text: "d\n") try await pm.sendStdin(id: "t5", text: "d\n")
let events = await evs let events = await evs
#expect(events.contains(.stdout(id: "t5", line: "got: "))) XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: ")))
#expect(events.contains(.stdout(id: "t5", line: "got:d"))) XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d")))
} }
@Test func duplicateIDRejected() async throws { func testDuplicateIDRejected() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n") let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "t6", binary: bin, arguments: []) 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: []) try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
} errorHandler: { error in
XCTAssertEqual(error, .duplicateID("t6"))
} }
await pm.kill(id: "t6") await pm.kill(id: "t6")
} }
@Test func killEmitsExitAndClosesStdin() async throws { func testKillEmitsExitAndClosesStdin() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n") let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
async let evs = collect(pm, id: "t7") async let evs = collect(pm, id: "t7")
@@ -200,49 +202,51 @@ struct ProcessManagerTests {
let events = await evs let events = await evs
// exit emitted exactly once // exit emitted exactly once
let exits = events.filter { if case .exit = $0 { return true }; return false } let exits = events.filter { if case .exit = $0 { return true }; return false }
#expect(exits.count == 1) XCTAssertEqual(exits.count, 1)
await #expect(throws: ProcessError.unknownID("t7")) { await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.sendStdin(id: "t7", text: "d\n") 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 pm = ProcessManager()
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n") 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: "a", binary: bin, arguments: [])
try await pm.runStreaming(id: "b", binary: bin, arguments: []) try await pm.runStreaming(id: "b", binary: bin, arguments: [])
let count = await pm.killAll() let count = await pm.killAll()
#expect(count == 2) XCTAssertEqual(count, 2)
} }
@Test func capturedRunReturnsBothStreams() async throws { func testCapturedRunReturnsBothStreams() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n") 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: []) let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
#expect(result.stdout.contains("out-data")) XCTAssertTrue(result.stdout.contains("out-data"))
#expect(result.stderr.contains("err-data")) XCTAssertTrue(result.stderr.contains("err-data"))
#expect(result.exitCode == 3) XCTAssertEqual(result.exitCode, 3)
} }
@Test func capturedRunFastExit() async throws { func testCapturedRunFastExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n") let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
#expect(result.exitCode == 7) XCTAssertEqual(result.exitCode, 7)
#expect(result.stdout == "") XCTAssertEqual(result.stdout, "")
#expect(result.stderr == "") XCTAssertEqual(result.stderr, "")
} }
@Test func capturedRunStderrOnly() async throws { func testCapturedRunStderrOnly() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n") 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: []) let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
#expect(result.exitCode == 1) XCTAssertEqual(result.exitCode, 1)
#expect(result.stdout == "") XCTAssertEqual(result.stdout, "")
#expect(result.stderr.contains("mock lp failure")) XCTAssertTrue(result.stderr.contains("mock lp failure"))
} }
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws { func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws {
let pm = ProcessManager() let pm = ProcessManager()
// 5000 lines each stream exceeds the 64 KiB pipe buffer. // 5000 lines each stream exceeds the 64 KiB pipe buffer.
let bin = try script( let bin = try script(
@@ -250,26 +254,29 @@ struct ProcessManagerTests {
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n" "#!/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: []) let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
#expect(result.stdout.contains("out-4999")) XCTAssertTrue(result.stdout.contains("out-4999"))
#expect(result.stderr.contains("err-4999")) XCTAssertTrue(result.stderr.contains("err-4999"))
} }
@Test func argyllEnvVarIsSet() async throws { func testArgyllEnvVarIsSet() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n") let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
async let evs = collect(pm, id: "t10") async let evs = collect(pm, id: "t10")
try await pm.runStreaming(id: "t10", binary: bin, arguments: []) 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() let pm = ProcessManager()
await #expect(throws: ProcessError.unknownID("nope")) { await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.sendStdin(id: "nope", text: "d\n") 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 pm = ProcessManager()
let marker = Self.fixtureDir let marker = Self.fixtureDir
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)") .appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
@@ -280,7 +287,8 @@ struct ProcessManagerTests {
let box = Box() let box = Box()
let observer = observe(pm, id: "t11", into: box) let observer = observe(pm, id: "t11", into: box)
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path]) 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 // Retry the flush so the pipe-ingest task can win the actor race
// on a loaded host; the first successful flush emits the row. // on a loaded host; the first successful flush emits the row.
var flushed = false var flushed = false
@@ -290,24 +298,25 @@ struct ProcessManagerTests {
flushed = true flushed = true
break 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") await pm.kill(id: "t11")
#expect(await waitForExit(in: box)) let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
observer.cancel() observer.cancel()
let events = box.events let events = box.events
let rows = events.compactMap { e -> String? in let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
#expect(rows == ["{\"row\":9}"]) XCTAssertEqual(rows, ["{\"row\":9}"])
// Prefixed tails must not leak into stdout, even via finalize. // Prefixed tails must not leak into stdout, even via finalize.
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}"))) XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
#expect(exitCount(in: box) == 1) XCTAssertEqual(exitCount(in: box), 1)
} }
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws { func testUnterminatedRowTailFinalizesAsJSONRow() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"row-tail.sh", "row-tail.sh",
@@ -316,68 +325,70 @@ struct ProcessManagerTests {
let box = Box() let box = Box()
let observer = observe(pm, id: "t12", into: box) let observer = observe(pm, id: "t12", into: box)
try await pm.runStreaming(id: "t12", binary: bin, arguments: []) try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
#expect(await waitForExit(in: box)) let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
observer.cancel() observer.cancel()
let events = box.events let events = box.events
let rows = events.compactMap { e -> String? in let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
#expect(rows == ["{\"row\":42}"]) XCTAssertEqual(rows, ["{\"row\":42}"])
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}"))) XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
let rowIndex = events.firstIndex { let rowIndex = events.firstIndex {
if case .jsonRow = $0 { return true }; return false if case .jsonRow = $0 { return true }; return false
} }
let exitIndexes = events.indices.filter { let exitIndexes = events.indices.filter {
if case .exit = events[$0] { return true }; return false if case .exit = events[$0] { return true }; return false
} }
#expect(exitIndexes.count == 1) XCTAssertEqual(exitIndexes.count, 1)
if let rowIndex, let exitIndex = exitIndexes.first { if let rowIndex, let exitIndex = exitIndexes.first {
#expect(rowIndex < exitIndex) XCTAssertTrue(rowIndex < exitIndex)
} else { } 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 pm = ProcessManager()
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n") let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
let box = Box() let box = Box()
let observer = observe(pm, id: "t13", into: box) let observer = observe(pm, id: "t13", into: box)
try await pm.runStreaming(id: "t13", binary: bin, arguments: []) 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 // The grace window must outlast the 2 s finalize watchdog so a
// duplicate emission from it would be observed. // 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() 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 pm = ProcessManager()
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n") let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
let box = Box() let box = Box()
let observer = observe(pm, id: "t14", into: box) let observer = observe(pm, id: "t14", into: box)
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: []) 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 // Both the termination handler and the waitUntilExit watchdog
// resume the same box; give the slower path time to fire. // 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() 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 pm = ProcessManager()
let bin = try script( let bin = try script(
"cap-env.sh", "cap-env.sh",
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n" "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
) )
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: []) 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 pm = ProcessManager()
let marker = Self.fixtureDir let marker = Self.fixtureDir
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)") .appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
@@ -391,21 +402,29 @@ struct ProcessManagerTests {
let capTask = Task { let capTask = Task {
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path]) try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
} }
#expect(await waitForFile(marker)) let markerReady = await waitForFile(marker)
#expect(await waitForRunning(pm, id: "t16")) XCTAssertTrue(markerReady)
#expect(await waitForRunning(pm, id: "t17")) let t16Running = await waitForRunning(pm, id: "t16")
#expect(await pm.killAll() == 2) XCTAssertTrue(t16Running)
let t17Running = await waitForRunning(pm, id: "t17")
XCTAssertTrue(t17Running)
let killed = await pm.killAll()
XCTAssertEqual(killed, 2)
_ = try await capTask.value _ = try await capTask.value
#expect(await waitForExit(in: streamBox)) let streamExit = await waitForExit(in: streamBox)
#expect(await waitForExit(in: capBox)) XCTAssertTrue(streamExit)
let capExit = await waitForExit(in: capBox)
XCTAssertTrue(capExit)
// Grace window outlasts the streaming finalize watchdog. // 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() streamObserver.cancel()
capObserver.cancel() capObserver.cancel()
#expect(!(await pm.isRunning("t16"))) let t16RunningAfter = await pm.isRunning("t16")
#expect(!(await pm.isRunning("t17"))) XCTAssertFalse(t16RunningAfter)
#expect(exitCount(in: streamBox) == 1) let t17RunningAfter = await pm.isRunning("t17")
#expect(exitCount(in: capBox) == 1) XCTAssertFalse(t17RunningAfter)
XCTAssertEqual(exitCount(in: streamBox), 1)
XCTAssertEqual(exitCount(in: capBox), 1)
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCery @testable import ICCery
/// Direct contracts for the shared logged-run helper (issue #80). /// 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`) /// `runLogged` owns the running-flag transition (`false true false`)
/// and the log-reset decision; these tests pin both sides of the /// and the log-reset decision; these tests pin both sides of the
/// contract plus the coalesced `@MainActor` log hop. /// contract plus the coalesced `@MainActor` log hop.
@Suite("ProcessRunSupport runLogged")
@MainActor @MainActor
struct ProcessRunSupportTests { final class ProcessRunSupportTests: XCTestCase {
private struct SentinelError: Error {} private struct SentinelError: Error {}
@Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved") func testSuccessTransitions() async throws {
func successTransitions() async throws {
var running: [Bool] = [] var running: [Bool] = []
var resets = 0 var resets = 0
var received: [String] = [] var received: [String] = []
@@ -23,7 +21,9 @@ struct ProcessRunSupportTests {
setRunning: { running.append($0) }, setRunning: { running.append($0) },
resetLog: { resets += 1 }, resetLog: { resets += 1 },
onLog: { batch in 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) received.append(contentsOf: batch)
} }
) { onLog in ) { onLog in
@@ -31,20 +31,19 @@ struct ProcessRunSupportTests {
return 42 return 42
} }
#expect(result == 42) XCTAssertEqual(result, 42)
#expect(running == [true, false]) XCTAssertEqual(running, [true, false])
#expect(resets == 1) XCTAssertEqual(resets, 1)
// The sink hops back through a main-actor Task; yield until the // The sink hops back through a main-actor Task; yield until the
// coalesced batch lands. // coalesced batch lands.
for _ in 0..<200 where received.isEmpty { 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 testFailureTransitions() async throws {
func failureTransitions() async throws {
var running: [Bool] = [] var running: [Bool] = []
var resets = 0 var resets = 0
@@ -56,12 +55,12 @@ struct ProcessRunSupportTests {
) { _ -> Int in ) { _ -> Int in
throw SentinelError() throw SentinelError()
} }
Issue.record("Expected runLogged to rethrow") XCTFail("Expected runLogged to rethrow")
} catch is SentinelError { } catch is SentinelError {
// Expected path. // Expected path.
} }
#expect(running == [true, false]) XCTAssertEqual(running, [true, false])
#expect(resets == 1) XCTAssertEqual(resets, 1)
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// A `FileManager` subclass that reports a temporary directory as the /// A `FileManager` subclass that reports a temporary directory as the
@@ -18,8 +18,7 @@ private final class TestFileManager: FileManager {
} }
} }
@Suite("ProfileInstaller") final class ProfileInstallerTests: XCTestCase {
struct ProfileInstallerTests {
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let fm = FileManager.default let fm = FileManager.default
@@ -39,8 +38,7 @@ struct ProfileInstallerTests {
return url return url
} }
@Test("Installs .icc to user ColorSync folder") func testUserInstall() throws {
func userInstall() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -51,15 +49,14 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(result.registered) XCTAssertTrue(result.registered)
#expect(!result.overwritten) XCTAssertFalse(result.overwritten)
#expect(!result.renamed) XCTAssertFalse(result.renamed)
#expect(result.destPath.hasSuffix("test.icc")) XCTAssertTrue(result.destPath.hasSuffix("test.icc"))
#expect(fm.fileExists(atPath: result.destPath)) XCTAssertTrue(fm.fileExists(atPath: result.destPath))
} }
@Test("Overwrite succeeds and replaces the existing file") func testOverwriteSucceeds() throws {
func overwriteSucceeds() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -70,7 +67,7 @@ struct ProfileInstallerTests {
config: InstallProfileConfig(sourceURL: source), config: InstallProfileConfig(sourceURL: source),
fileManager: testFM fileManager: testFM
) )
#expect(!first.overwritten) XCTAssertFalse(first.overwritten)
// Change the source contents. // Change the source contents.
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) } let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
@@ -87,15 +84,14 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(second.overwritten) XCTAssertTrue(second.overwritten)
#expect(!second.renamed) XCTAssertFalse(second.renamed)
#expect(fm.fileExists(atPath: second.destPath)) XCTAssertTrue(fm.fileExists(atPath: second.destPath))
let installed = try Data(contentsOf: URL(fileURLWithPath: 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 testPreservesIcmExtension() throws {
func preservesIcmExtension() throws {
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icm") let source = try makeSource(at: tmp, name: "m5_profile.icm")
@@ -105,12 +101,11 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm") XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm")
#expect(result.destPath.hasSuffix("m5_profile.icm")) XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm"))
} }
@Test("Rejects parent traversal in source path") func testRejectsParentTraversal() throws {
func rejectsParentTraversal() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
@@ -125,20 +120,19 @@ struct ProfileInstallerTests {
let sourceURL = tmp let sourceURL = tmp
.appendingPathComponent("..") .appendingPathComponent("..")
.appendingPathComponent(naughtyName) .appendingPathComponent(naughtyName)
#expect(fm.fileExists(atPath: sourceURL.path)) XCTAssertTrue(fm.fileExists(atPath: sourceURL.path))
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
Issue.record("Expected unsafeStem error") XCTFail("Expected unsafeStem error")
} catch let error as ProfileInstallError { } 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 { } catch {
Issue.record("Unexpected error type: \(error)") XCTFail("Unexpected error type: \(error)")
} }
} }
@Test("Allows stems with consecutive dots like foo..bar") func testAllowsDoubleDotStem() throws {
func allowsDoubleDotStem() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -149,23 +143,22 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(result.destPath.hasSuffix("foo..bar.icc")) XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc"))
#expect(fm.fileExists(atPath: result.destPath)) XCTAssertTrue(fm.fileExists(atPath: result.destPath))
} }
@Test("Rejects source files that are too small") func testRejectsSmallSource() throws {
func rejectsSmallSource() throws {
let tmp = try makeTempDir() let tmp = try makeTempDir()
let source = tmp.appendingPathComponent("tiny.icc") let source = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: source) try Data(repeating: 0, count: 64).write(to: source)
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
Issue.record("Expected sourceTooSmall error") XCTFail("Expected sourceTooSmall error")
} catch let error as ProfileInstallError { } 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 { } catch {
Issue.record("Unexpected error type: \(error)") XCTFail("Unexpected error type: \(error)")
} }
} }
} }
+54 -57
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
private func tempStoreURL() -> URL { private func tempStoreURL() -> URL {
@@ -8,92 +8,90 @@ private func tempStoreURL() -> URL {
.appendingPathComponent("settings.json") .appendingPathComponent("settings.json")
} }
@Suite("AppSettings") final class AppSettingsTests: XCTestCase {
struct AppSettingsTests { func testDefaults() {
@Test func defaults() {
let s = AppSettings.default let s = AppSettings.default
#expect(s.argyllBinaryDir == nil) XCTAssertNil(s.argyllBinaryDir)
#expect(s.defaultInstrument == nil) XCTAssertNil(s.defaultInstrument)
#expect(s.logLevel == nil) XCTAssertNil(s.logLevel)
#expect(s.deltaEGoodMax == 2.0) XCTAssertEqual(s.deltaEGoodMax, 2.0)
#expect(s.deltaEWarningMax == 5.0) XCTAssertEqual(s.deltaEWarningMax, 5.0)
#expect(s.customPresets.isEmpty) XCTAssertTrue(s.customPresets.isEmpty)
#expect(!s.enableI1Pro2Leds) XCTAssertFalse(s.enableI1Pro2Leds)
#expect(s.calibrationStaleDays == 30) XCTAssertEqual(s.calibrationStaleDays, 30)
#expect(s.defaultInstallLocation == .user) XCTAssertEqual(s.defaultInstallLocation, .user)
#expect(s.askBeforeOverwriteProfile) XCTAssertTrue(s.askBeforeOverwriteProfile)
#expect(!s.openColorPanelAfterInstall) XCTAssertFalse(s.openColorPanelAfterInstall)
#expect(s.isValid) XCTAssertTrue(s.isValid)
} }
@Test func negativeThresholds() { func testNegativeThresholds() {
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = -1 s.deltaEGoodMax = -1
#expect(s.validate() == [AppSettings.errorNegativeDeltaE]) XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE])
s.deltaEGoodMax = 2.0 s.deltaEGoodMax = 2.0
s.deltaEWarningMax = -0.5 s.deltaEWarningMax = -0.5
// -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too // -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too
#expect(s.validate() == [ XCTAssertTrue(s.validate() == [
AppSettings.errorNegativeDeltaE, AppSettings.errorNegativeDeltaE,
AppSettings.errorThresholdOrder, AppSettings.errorThresholdOrder,
]) ])
} }
@Test func goodMustBeStrictlyLessThanWarning() { func testGoodMustBeStrictlyLessThanWarning() {
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 5.0 s.deltaEGoodMax = 5.0
#expect(s.validate() == [AppSettings.errorThresholdOrder]) XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 6.0 s.deltaEGoodMax = 6.0
#expect(s.validate() == [AppSettings.errorThresholdOrder]) XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 4.9 s.deltaEGoodMax = 4.9
#expect(s.isValid) XCTAssertTrue(s.isValid)
} }
@Test func snakeCaseKeys() throws { func testSnakeCaseKeys() throws {
let s = AppSettings.default let s = AppSettings.default
let data = try JSONEncoder().encode(s) let data = try JSONEncoder().encode(s)
let json = String(data: data, encoding: .utf8)! let json = String(data: data, encoding: .utf8)!
#expect(json.contains("\"delta_e_good_max\"")) XCTAssertTrue(json.contains("\"delta_e_good_max\""))
#expect(json.contains("\"default_install_location\"")) XCTAssertTrue(json.contains("\"default_install_location\""))
#expect(json.contains("\"enable_i1pro2_leds\"")) XCTAssertTrue(json.contains("\"enable_i1pro2_leds\""))
} }
} }
@Suite("SettingsStore") final class SettingsStoreTests: XCTestCase {
struct SettingsStoreTests { func testRoundTrip() throws {
@Test func roundTrip() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 1.5 s.deltaEGoodMax = 1.5
s.defaultInstrument = "p3" s.defaultInstrument = "p3"
try store.save(s) try store.save(s)
#expect(store.load() == s) XCTAssertEqual(store.load(), s)
} }
@Test func corruptJsonFallsBackToDefaults() throws { func testCorruptJsonFallsBackToDefaults() throws {
let url = tempStoreURL() let url = tempStoreURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "{ not json".write(to: url, atomically: true, encoding: .utf8) 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() { func testMissingFileReturnsDefaults() {
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default) XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default)
} }
@Test func invalidSettingsNotPersisted() throws { func testInvalidSettingsNotPersisted() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 9.0 // >= warning 5.0 s.deltaEGoodMax = 9.0 // >= warning 5.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) } XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
#expect(!FileManager.default.fileExists(atPath: url.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
} }
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws { func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var valid = AppSettings.default var valid = AppSettings.default
@@ -109,13 +107,13 @@ struct SettingsStoreTests {
var invalid = AppSettings.default var invalid = AppSettings.default
invalid.deltaEGoodMax = 9.0 invalid.deltaEGoodMax = 9.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) } XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
#expect(try Data(contentsOf: url) == originalBytes) XCTAssertEqual(try Data(contentsOf: url), originalBytes)
#expect(!fired) XCTAssertFalse(fired)
#expect(store.load() == valid) XCTAssertEqual(store.load(), valid)
} }
@Test func savePostsNotification() async throws { func testSavePostsNotification() async throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var fired = false var fired = false
@@ -124,12 +122,11 @@ struct SettingsStoreTests {
) { _ in fired = true } ) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) } defer { NotificationCenter.default.removeObserver(token) }
try store.save(.default) try store.save(.default)
#expect(fired) XCTAssertTrue(fired)
} }
} }
@Suite("LogSink") final class LogSinkTests: XCTestCase {
struct LogSinkTests {
private func tempLog() -> (URL, LogSink) { private func tempLog() -> (URL, LogSink) {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-log-\(UUID().uuidString)") .appendingPathComponent("iccery-log-\(UUID().uuidString)")
@@ -137,26 +134,26 @@ struct LogSinkTests {
return (url, LogSink(fileURL: url)) return (url, LogSink(fileURL: url))
} }
@Test func writesFormattedLines() { func testWritesFormattedLines() {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.debug) sink.setLevel(.debug)
sink.write(level: .info, category: "test", message: "hello") sink.write(level: .info, category: "test", message: "hello")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" 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() let (url, sink) = tempLog()
sink.setLevel(.error) sink.setLevel(.error)
sink.write(level: .info, category: "t", message: "hidden") sink.write(level: .info, category: "t", message: "hidden")
sink.setLevel(.info) // runtime change, no restart (#158) sink.setLevel(.info) // runtime change, no restart (#158)
sink.write(level: .info, category: "t", message: "shown") sink.write(level: .info, category: "t", message: "shown")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(!content.contains("hidden")) XCTAssertFalse(content.contains("hidden"))
#expect(content.contains("shown")) XCTAssertTrue(content.contains("shown"))
} }
@Test func rotatesAt5MiBKeeping5Segments() throws { func testRotatesAt5MiBKeeping5Segments() throws {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.trace) sink.setLevel(.trace)
// Pre-fill the active log just under the cap, then cross it. // 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) try big.write(to: url, atomically: true, encoding: .utf8)
sink.write(level: .info, category: "t", message: "trigger rotation") sink.write(level: .info, category: "t", message: "trigger rotation")
#expect(FileManager.default.fileExists( XCTAssertTrue(FileManager.default.fileExists(
atPath: url.appendingPathExtension("1").path atPath: url.appendingPathExtension("1").path
)) ))
// Active log is small again. // Active log is small again.
let size = try FileManager.default.attributesOfItem( let size = try FileManager.default.attributesOfItem(
atPath: url.path atPath: url.path
)[.size] as? UInt64 )[.size] as? UInt64
#expect((size ?? 0) < 1024) XCTAssertTrue((size ?? 0) < 1024)
} }
@Test func tailExcerptCaps() throws { func testTailExcerptCaps() throws {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.debug) sink.setLevel(.debug)
sink.write(level: .info, category: "t", message: "line") sink.write(level: .info, category: "t", message: "line")
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8) XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8)
} }
} }
+15 -16
View File
@@ -1,4 +1,3 @@
import Testing
import XCTest import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -220,11 +219,9 @@ final class TargenArgsTests: XCTestCase {
} }
} }
@Suite("ArgyllRunner Targen") final class ArgyllRunnerTargenTests: XCTestCase {
struct ArgyllRunnerTargenTests {
@Test("Successful targen execution creates .ti1 and returns URL") func testSuccessfulTargenExecution() async throws {
func successfulTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -270,14 +267,13 @@ struct ArgyllRunnerTargenTests {
box.append(batch) box.append(batch)
} }
logLines = box.lines logLines = box.lines
#expect(logLines.contains("Generating patches...")) XCTAssertTrue(logLines.contains("Generating patches..."))
#expect(FileManager.default.fileExists(atPath: ti1URL.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path))
#expect(ti1URL.lastPathComponent == "mock_test.ti1") XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1")
} }
@Test("Failed targen execution throws toolFailed") func testFailedTargenExecution() async throws {
func failedTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -304,14 +300,15 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.toolFailed( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
try await runner.runTargen(config: config) 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 testMissingArtefactThrows() async throws {
func missingArtefactThrows() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -338,9 +335,11 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.missingArtefact( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tempDir.appendingPathComponent("no_file.ti1").path)) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(
tempDir.appendingPathComponent("no_file.ti1").path))
} }
} }
} }
@@ -1,17 +1,15 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Dataset-import error contracts through the /// Dataset-import error contracts through the
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O /// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
/// failures must surface identically as a single `.error` Notice. /// failures must surface identically as a single `.error` Notice.
@Suite("TargetWorkflowViewModel dataset import")
@MainActor @MainActor
struct TargetWorkflowViewModelTests { final class TargetWorkflowViewModelTests: XCTestCase {
@Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'") func testMalformedDatasetNotice() throws {
func malformedDatasetNotice() throws {
let env = try TestAppEnvironment.make() let env = try TestAppEnvironment.make()
defer { env.cleanup() } defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment) let vm = TargetWorkflowViewModel(environment: env.environment)
@@ -21,13 +19,12 @@ struct TargetWorkflowViewModelTests {
vm.importMeasurementDataset(from: bad) vm.importMeasurementDataset(from: bad)
let notice = try #require(vm.wizard.notice) let notice = try XCTUnwrap(vm.wizard.notice)
#expect(notice.kind == .error) XCTAssertEqual(notice.kind, .error)
#expect(notice.text.hasPrefix("Import failed:")) XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
} }
@Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'") func testMissingDatasetNotice() throws {
func missingDatasetNotice() throws {
let env = try TestAppEnvironment.make() let env = try TestAppEnvironment.make()
defer { env.cleanup() } defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment) let vm = TargetWorkflowViewModel(environment: env.environment)
@@ -35,8 +32,8 @@ struct TargetWorkflowViewModelTests {
let missing = env.root.appendingPathComponent("does-not-exist.ti3") let missing = env.root.appendingPathComponent("does-not-exist.ti3")
vm.importMeasurementDataset(from: missing) vm.importMeasurementDataset(from: missing)
let notice = try #require(vm.wizard.notice) let notice = try XCTUnwrap(vm.wizard.notice)
#expect(notice.kind == .error) XCTAssertEqual(notice.kind, .error)
#expect(notice.text.hasPrefix("Import failed:")) XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
} }
} }
@@ -1,12 +1,10 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("VerificationHistoryStore") final class VerificationHistoryStoreTests: XCTestCase {
struct VerificationHistoryStoreTests {
@Test("Append and cap") func testAppendAndCap() async throws {
func appendAndCap() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -29,12 +27,11 @@ struct VerificationHistoryStoreTests {
} }
let all = await store.all() let all = await store.all()
#expect(all.count == 3) XCTAssertEqual(all.count, 3)
#expect(all.first?.avgDE == 2.0) XCTAssertEqual(all.first?.avgDE, 2.0)
} }
@Test("Parse failure preserves file") func testParseFailurePreservesFile() async {
func parseFailurePreservesFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -45,14 +42,13 @@ struct VerificationHistoryStoreTests {
let store = VerificationHistoryStore(url: url) let store = VerificationHistoryStore(url: url)
do { do {
_ = try await store.load() _ = try await store.load()
Issue.record("load() should throw on invalid JSON") XCTFail("load() should throw on invalid JSON")
} catch { } catch {
#expect(fm.fileExists(atPath: url.path)) XCTAssertTrue(fm.fileExists(atPath: url.path))
} }
} }
@Test("Append loads existing records first") func testAppendLoadsExisting() async throws {
func appendLoadsExisting() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -89,13 +85,12 @@ struct VerificationHistoryStoreTests {
_ = try await store2.append(new) _ = try await store2.append(new)
let all = await store2.all() let all = await store2.all()
#expect(all.count == 2) XCTAssertEqual(all.count, 2)
#expect(all.contains { $0.id == "vr-existing" }) XCTAssertTrue(all.contains { $0.id == "vr-existing" })
#expect(all.contains { $0.id == "vr-new" }) XCTAssertTrue(all.contains { $0.id == "vr-new" })
} }
@Test("Append does not overwrite an unparseable file") func testAppendPreservesUnparseableFile() async {
func appendPreservesUnparseableFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -119,20 +114,19 @@ struct VerificationHistoryStoreTests {
do { do {
_ = try await store.append(record) _ = try await store.append(record)
Issue.record("append() should propagate the load error") XCTFail("append() should propagate the load error")
} catch { } catch {
#expect(fm.fileExists(atPath: url.path)) XCTAssertTrue(fm.fileExists(atPath: url.path))
if let data = try? Data(contentsOf: url), if let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8) { let contents = String(data: data, encoding: .utf8) {
#expect(contents == badJSON) XCTAssertEqual(contents, badJSON)
} else { } else {
Issue.record("Could not read preserved file") XCTFail("Could not read preserved file")
} }
} }
} }
@Test("Clear does not overwrite an unparseable file") func testClearPreservesUnparseableFile() async {
func clearPreservesUnparseableFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -144,15 +138,14 @@ struct VerificationHistoryStoreTests {
let store = VerificationHistoryStore(url: url) let store = VerificationHistoryStore(url: url)
do { do {
try await store.clear() try await store.clear()
Issue.record("clear() should propagate the load error") XCTFail("clear() should propagate the load error")
} catch { } catch {
let contents = try? String(contentsOf: url, encoding: .utf8) 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 testIso8601RoundTrip() async throws {
func iso8601RoundTrip() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -174,16 +167,15 @@ struct VerificationHistoryStoreTests {
_ = try await store1.append(record) _ = try await store1.append(record)
let text = try String(contentsOf: url, encoding: .utf8) 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 store2 = VerificationHistoryStore(url: url)
let loaded = try await store2.load() let loaded = try await store2.load()
#expect(loaded.count == 1) XCTAssertEqual(loaded.count, 1)
#expect(loaded.first?.timestamp == timestamp) XCTAssertEqual(loaded.first?.timestamp, timestamp)
} }
@Test("CSV export quoting") func testCsvQuoting() async throws {
func csvQuoting() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -204,7 +196,7 @@ struct VerificationHistoryStoreTests {
_ = try await store.append(record) _ = try await store.append(record)
let csv = await store.exportCSV() let csv = await store.exportCSV()
#expect(csv.contains("\"a,b\"")) XCTAssertTrue(csv.contains("\"a,b\""))
#expect(csv.contains("\"\"quoted\"\"")) XCTAssertTrue(csv.contains("\"\"quoted\"\""))
} }
} }
@@ -1,13 +1,12 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #29 `CAL_` basename must be restored on relaunch and on any /// Issue #29 `CAL_` basename must be restored on relaunch and on any
/// attempt to navigate to a non-calibration stage that would use it. /// attempt to navigate to a non-calibration stage that would use it.
@Suite("WizardCalibrationSession")
@MainActor @MainActor
struct WizardCalibrationSessionTests { final class WizardCalibrationSessionTests: XCTestCase {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
@@ -24,8 +23,7 @@ struct WizardCalibrationSessionTests {
return url return url
} }
@Test("Persist and restore calibrationOriginalBasename across a relaunch") func testRelaunchRestoresOriginal() throws {
func relaunchRestoresOriginal() throws {
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
var saved = WizardState( var saved = WizardState(
@@ -39,14 +37,13 @@ struct WizardCalibrationSessionTests {
let model = WizardViewModel(stateStore: store) let model = WizardViewModel(stateStore: store)
#expect(model.basename == "DemoTarget") XCTAssertEqual(model.basename, "DemoTarget")
#expect(model.calibrationOriginalBasename == "") XCTAssertEqual(model.calibrationOriginalBasename, "")
#expect(model.sessionMode == .profile) XCTAssertEqual(model.sessionMode, .profile)
#expect(model.stage == .generate) XCTAssertEqual(model.stage, .generate)
} }
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original") func testGoToBuildProfileRefusesAndRestores() throws {
func goToBuildProfileRefusesAndRestores() throws {
let dir = try tempDir() let dir = try tempDir()
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
@@ -60,14 +57,13 @@ struct WizardCalibrationSessionTests {
model.go(to: .buildProfile) model.go(to: .buildProfile)
#expect(model.basename == "DemoTarget") XCTAssertEqual(model.basename, "DemoTarget")
#expect(model.calibrationOriginalBasename == "") XCTAssertEqual(model.calibrationOriginalBasename, "")
#expect(model.sessionMode == .profile) XCTAssertEqual(model.sessionMode, .profile)
#expect(model.stage == .calibrate) XCTAssertEqual(model.stage, .calibrate)
} }
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration") func testGoToLayoutStaysCal() throws {
func goToLayoutStaysCal() throws {
let dir = try tempDir() let dir = try tempDir()
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
@@ -81,9 +77,9 @@ struct WizardCalibrationSessionTests {
model.go(to: .layOutPrint) model.go(to: .layOutPrint)
#expect(model.basename == "CAL_DemoTarget") XCTAssertEqual(model.basename, "CAL_DemoTarget")
#expect(model.calibrationOriginalBasename == "DemoTarget") XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget")
#expect(model.sessionMode == .calibration) XCTAssertEqual(model.sessionMode, .calibration)
#expect(model.stage == .layOutPrint) XCTAssertEqual(model.stage, .layOutPrint)
} }
} }
+47 -49
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
private func artefacts( private func artefacts(
@@ -16,77 +16,75 @@ private func artefacts(
return a return a
} }
@Suite("WizardGating matrix") final class WizardGatingTests: XCTestCase {
struct WizardGatingTests {
@Test func emptyProjectOnlyStage1() { func testEmptyProjectOnlyStage1() {
let a = artefacts() let a = artefacts()
#expect(WizardGating.isUnlocked(.generate, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a))
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a))
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] { 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) let a = artefacts(ti1: true)
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
#expect(!WizardGating.isUnlocked(.measure, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a))
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a))
} }
@Test func stage3NeedsTi1AndTi2() { func testStage3NeedsTi1AndTi2() {
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, 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. // #109/#110: .ti2 alone must never unlock Stage 4.
let a = artefacts(ti1: true, ti2: true) let a = artefacts(ti1: true, ti2: true)
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
} }
@Test func stage5NeedsTi3AndProfile() { func testStage5NeedsTi3AndProfile() {
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
#expect(WizardGating.isUnlocked( XCTAssertTrue(WizardGating.isUnlocked(
.verifyInstall, artefacts: artefacts(ti3: true, profile: true) .verifyInstall, artefacts: artefacts(ti3: true, profile: true)
)) ))
} }
@Test func forwardGatedBackwardFree() { func testForwardGatedBackwardFree() {
let a = artefacts() 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. // 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. // 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. // 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() { func testDeepestUnlocked() {
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate) XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate)
#expect(WizardGating.deepestUnlocked( XCTAssertEqual(WizardGating.deepestUnlocked(
artefacts: artefacts(ti1: true, ti2: true) artefacts: artefacts(ti1: true, ti2: true)
) == .measure) ), .measure)
#expect(WizardGating.deepestUnlocked( XCTAssertEqual(WizardGating.deepestUnlocked(
artefacts: artefacts(ti3: true, profile: true) artefacts: artefacts(ti3: true, profile: true)
) == .verifyInstall) ), .verifyInstall)
} }
} }
@Suite("WizardStateStore") final class WizardStateStoreTests: XCTestCase {
struct WizardStateStoreTests {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)") .appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
.appendingPathComponent("wizard_state.json") .appendingPathComponent("wizard_state.json")
} }
@Test func roundTrip() throws { func testRoundTrip() throws {
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
var s = WizardState() var s = WizardState()
@@ -97,43 +95,43 @@ struct WizardStateStoreTests {
s.profileBasename = "imported" s.profileBasename = "imported"
s.calibrationOriginalBasename = "pre-cal" s.calibrationOriginalBasename = "pre-cal"
try store.save(s) try store.save(s)
#expect(store.load() == s) XCTAssertEqual(store.load(), s)
} }
@Test func missingFileDefaults() { func testMissingFileDefaults() {
let s = WizardStateStore(fileURL: tempURL()).load() let s = WizardStateStore(fileURL: tempURL()).load()
#expect(s == .default) XCTAssertEqual(s, .default)
#expect(s.stage == .generate) XCTAssertEqual(s.stage, .generate)
#expect(s.sessionMode == .profile) XCTAssertEqual(s.sessionMode, .profile)
} }
@Test func corruptStageFallsBackToGenerate() throws { func testCorruptStageFallsBackToGenerate() throws {
let url = tempURL() let url = tempURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"# try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
.write(to: url, atomically: true, encoding: .utf8) .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() let url = tempURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "not json".write(to: url, atomically: true, encoding: .utf8) 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) 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) var s = WizardState(sessionMode: .calibration)
let data = try JSONEncoder().encode(s) let data = try JSONEncoder().encode(s)
let decoded = try JSONDecoder().decode(WizardState.self, from: data) let decoded = try JSONDecoder().decode(WizardState.self, from: data)
#expect(decoded.sessionMode == .calibration) XCTAssertEqual(decoded.sessionMode, .calibration)
s.sessionMode = .profile s.sessionMode = .profile
#expect(s.sessionMode == .profile) XCTAssertEqual(s.sessionMode, .profile)
} }
} }
@@ -76,8 +76,15 @@ final class Milestone6CalibrationUITests: XCTestCase {
// After generation the wizard should advance to Stage 2 (layout) because // After generation the wizard should advance to Stage 2 (layout) because
// a CAL_ .ti1 now exists and the session is in calibration mode. // a CAL_ .ti1 now exists and the session is in calibration mode.
let layout = app.buttons["btnCreateLayout"] let layout = app.buttons["btnCreateLayout"]
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)) XCTAssertTrue(layout.waitForExistence(timeout: 25))
} }
}
/// A failing calibration targen surfaces the error through the /// A failing calibration targen surfaces the error through the
/// wizard notice and restores the original basename (issue #80). /// wizard notice and restores the original basename (issue #80).
+7
View File
@@ -62,6 +62,13 @@ targets:
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"] OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
MACOSX_DEPLOYMENT_TARGET: "12.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ARCHS: "$(ARCHS_STANDARD)" ARCHS: "$(ARCHS_STANDARD)"
# Debug builds sign ad-hoc; hardened-runtime library validation would
# reject the unsigned embedded ICCeryCore package framework when the
# test host launches (run 31992, #119). Release keeps validation and
# links the package statically anyway.
configs:
Debug:
DISABLE_LIBRARY_VALIDATION: YES
ICCeryCoreTests: ICCeryCoreTests:
type: bundle.unit-test type: bundle.unit-test
+40
View File
@@ -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