Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b2b85ae835 chore(m9): retarget project spec, CI, and core syntax to macOS 12 / Swift 5.7
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 20:02:57 +01:00
gronod 09f433106d Merge pull request 'Milestone 8 — UAT-ready hardening and consolidation (#79–#86)' (#104) from milestone/m8-consolidation into develop
macOS CI / build-and-test (push) Successful in 13m1s
macOS CI / package (push) Successful in 4m25s
2026-09-11 17:10:23 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 891a504ee7 test(ui): activate app after launch in calibration UI tests
macOS CI / build-and-test (pull_request) Successful in 11m22s
macOS CI / package (pull_request) Skipped
testCalibrationDashboardOpensAndCanGenerate failed in every full-suite
gate run while passing standalone: without an explicit activate() the
synthesized btnCalGenerate click was consumed by window activation when
focus sat on another app after the prior test app terminated. Matches
the launchApp() convention used by the other UI suites.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 15:15:16 +01:00
gronod f681e60778 Merge pull request 'refactor(ui): complete logged-run and Notice consolidation (#80)' (#103) from feat/80-process-run-notice-consolidation into milestone/m8-consolidation 2026-09-11 13:53:44 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0d0233e8d6 refactor(ui): complete logged-run and Notice consolidation (#80)
Refs #80

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 13:18:34 +01:00
gronod d117d7a510 Merge pull request 'test(runner): complete shared streaming loop contracts (#79)' (#102) from feat/79-runner-loop-contract-tests into milestone/m8-consolidation 2026-09-11 12:41:28 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 78ffeff61b test(runner): complete shared streaming loop contracts (#79)
Refs #79

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 12:33:16 +01:00
gronod 0298f69a2c Merge pull request 'refactor(args): finish shared option helpers (#86)' (#101) from feat/86-args-builder-completion into milestone/m8-consolidation 2026-09-11 12:20:36 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> da602e2775 refactor(args): finish shared option helpers (#86)
Adopt ArgsBuilder helpers across the remaining argument generators while
preserving byte-identical argv and exact flag ordering:

- TargenArgs: optionUnlessApprox for -N/-V/-p, optionIfNonEmpty for -c,
  flag for -G, option for -A.
- PrinttargArgs: flag for -r, optionIfNonEmpty for -d and the dynamic
  -K/-I calibration value (CAL_ protection retained).
- PrintcalArgs: flag for -I/-z, optionIfNonEmpty for -a.
- ColprofArgs left explicit: FWA and "none" viewing-condition branches
  cannot be represented by the helpers without changing argv.

New ArgsBuilderTests cover nil/present/empty/whitespace/trim, epsilon
boundaries, POSIX formatting, and flag handling. Added whitespace-only
option cases for targen -c, printtarg -d/-K/-I, and printcal -a, plus
chartread row decode and unkeyed XYZ/Lab wire-encoding tests.

Refs #86

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 12:17:52 +01:00
gronod 5e3b183b9a Merge pull request 'test(process): complete ProcessManager edge contracts (#84)' (#100) from feat/84-process-manager-edge-contracts into milestone/m8-consolidation 2026-09-11 11:59:07 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> e48c3f6840 test(process): complete ProcessManager edge contracts (#84)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 11:36:50 +01:00
gronod 7751701208 Merge pull request 'fix(profile): complete calibration identity and profile resolution (#83)' (#99) from feat/83-calibration-profile-contracts into milestone/m8-consolidation 2026-09-11 11:24:41 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c539507d5d fix(profile): complete calibration identity and profile resolution (#83)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 11:10:10 +01:00
gronod 0ffcf5ea91 Merge pull request 'fix(presets): complete config mapping contracts (#82)' (#98) from feat/82-preset-mapping-contracts into milestone/m8-consolidation 2026-09-11 10:58:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 597cce7b60 fix(presets): complete config mapping contracts (#82)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 10:53:41 +01:00
gronod bb4512e129 Merge pull request 'fix(persistence): complete M8 JSON store contracts (#81)' (#97) from feat/81-json-store-contracts into milestone/m8-consolidation 2026-09-11 10:38:53 +01:00
gronod 12584d156a fix(persistence): complete M8 JSON store contracts (#81) 2026-09-11 10:34:36 +01:00
gronod 0332a2bb4f docs: normalise milestone tracking and branch workflow for M8 in README 2026-09-11 10:02:38 +01:00
gronod 0d98440a15 Merge pull request 'Milestone/m7 grok' (#96) from milestone/M7-grok into develop
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
Reviewed-on: #96
2026-09-11 09:48:13 +01:00
gronod 073e3aa308 chore: normalize docs/megaplans/ gitignore pattern
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 17s
Replace `docs/megaplans/` with `docs/megaplans/*` and `docs/megaplans` to ensure both the directory contents and the directory itself are ignored consistently.
2026-09-11 09:32:56 +01:00
52 changed files with 1855 additions and 322 deletions
+10 -2
View File
@@ -14,13 +14,21 @@ jobs:
build-and-test: build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not, # Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline. # macos-14 works for this pipeline.
runs-on: macos-14 runs-on: macos-12
env: env:
DERIVED: build/DerivedData-test DERIVED: build/DerivedData-test
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Assert Xcode 14 toolchain
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
- name: Ensure host tools
run: |
command -v xcodegen || brew install xcodegen
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
- name: Generate Xcode project - name: Generate Xcode project
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
@@ -120,7 +128,7 @@ jobs:
package: package:
needs: build-and-test needs: build-and-test
runs-on: macos-14 runs-on: macos-12
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Checkout - name: Checkout
+2 -1
View File
@@ -27,4 +27,5 @@ ICCery.xcodeproj/
Release/ Release/
notarization/ notarization/
build/ build/
docs/megaplans/ docs/megaplans/*
docs/megaplans
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "ICCeryCore", name: "ICCeryCore",
platforms: [.macOS(.v14)], platforms: [.macOS(.v12)],
products: [ products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]), .library(name: "ICCeryCore", targets: ["ICCeryCore"]),
], ],
@@ -200,7 +200,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id) await processManager.kill(id: id)
var attempts = 0 var attempts = 0
while await processManager.isRunning(id), attempts < 30 { while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100)) try? await Task.sleep(nanoseconds: 100_000_000)
attempts += 1 attempts += 1
} }
} }
@@ -243,7 +243,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines { if flushPartialLines {
dotFlushTask = Task { [processManager] in dotFlushTask = Task { [processManager] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500)) try? await Task.sleep(nanoseconds: 500_000_000)
if Task.isCancelled { break } if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId) await processManager.flushPartialLine(id: processId)
} }
@@ -592,7 +592,7 @@ public struct ArgyllRunner: Sendable {
await processManager.setPreKillHook(id: processId) { [processManager] in await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY { if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(for: .milliseconds(500)) try? await Task.sleep(nanoseconds: 500_000_000)
} }
} }
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
} }
args.append(contentsOf: ["-R", "\(config.customSeed)"]) args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster: case .raster:
args.append("-r") args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
} }
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines), args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
guard (72...600).contains(config.dpi) else { guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi) throw PrinttargArgError.invalidDPI(config.dpi)
} }
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"]) args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !CalibrationIdentity.isCalibration(cleanBasename), if !CalibrationIdentity.isCalibration(cleanBasename) {
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines), args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
!cal.isEmpty { config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
} }
args.append(cleanBasename) args.append(cleanBasename)
@@ -70,18 +70,12 @@ public enum TargenArgs {
if let n = config.neutralSteps, n > 0 { if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"]) args.append(contentsOf: ["-n", "\(n)"])
} }
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 { args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)]) args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
} args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty { args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
args.append(contentsOf: ["-c", c]) String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
} }))
if config.ofpsHighQuality == true {
args.append("-G")
}
if let a = config.ofpsAdaptation {
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
}
if let algFlag = config.fullSpreadAlgorithm?.flag { if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag) args.append(algFlag)
} }
@@ -91,11 +85,9 @@ public enum TargenArgs {
} }
args.append(contentsOf: ["-l", "\(inkLimit)"]) args.append(contentsOf: ["-l", "\(inkLimit)"])
} }
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 { args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)]) if let p = config.devicePower, p > 0 {
} args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
} }
args.append(cleanBasename) args.append(cleanBasename)
@@ -72,7 +72,7 @@ public enum CGATSParser {
public static func parse( public static func parse(
_ contents: String, _ contents: String,
sourceURL: URL? = nil sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset { ) throws -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile } guard !contents.isEmpty else { throw .emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? "" let ext = sourceURL?.pathExtension.lowercased() ?? ""
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess( private static func preprocess(
_ contents: String, _ contents: String,
isCSV: Bool isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) { ) throws -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines) let allLines = contents.components(separatedBy: .newlines)
var lines = [String]() var lines = [String]()
@@ -79,14 +79,17 @@ public enum ArtefactProbe {
} }
/// Resolve an explicit profile URL, flipping `.icc` `.icm` when the /// Resolve an explicit profile URL, flipping `.icc` `.icm` when the
/// requested path is missing (#69 / issue #83). /// requested path is missing (#69 / issue #83). Any other extension
/// (`.mpp`, `.txt`, ) is returned unchanged never rewritten.
public static func resolveProfile( public static func resolveProfile(
_ url: URL, _ url: URL,
fileManager: FileManager = .default fileManager: FileManager = .default
) -> URL { ) -> URL {
if fileManager.fileExists(atPath: url.path) { return url } if fileManager.fileExists(atPath: url.path) { return url }
let altExt = url.pathExtension.lowercased() == "icc" ? "icm" : "icc" let ext = url.pathExtension.lowercased()
let alt = url.deletingPathExtension().appendingPathExtension(altExt) guard ext == "icc" || ext == "icm" else { return url }
let alt = url.deletingPathExtension()
.appendingPathExtension(ext == "icc" ? "icm" : "icc")
return fileManager.fileExists(atPath: alt.path) ? alt : url return fileManager.fileExists(atPath: alt.path) ? alt : url
} }
@@ -27,10 +27,7 @@ public struct JSONFileStore<T: Codable & Sendable>: Sendable {
self.fileURL = fileURL self.fileURL = fileURL
self.corrupt = corrupt self.corrupt = corrupt
self.defaultValue = defaultValue self.defaultValue = defaultValue
let encoder = JSONEncoder() self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding)
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = dateEncoding
self.encoder = encoder
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecoding decoder.dateDecodingStrategy = dateDecoding
self.decoder = decoder self.decoder = decoder
@@ -75,10 +72,14 @@ public struct JSONFileStore<T: Codable & Sendable>: Sendable {
} }
extension JSONEncoder { extension JSONEncoder {
/// Pretty-printed, sorted-keys encoder used by preset export. /// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore`
public static func icceryPretty() -> JSONEncoder { /// and preset export.
static func icceryPretty(
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate
) -> JSONEncoder {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = dateEncoding
return encoder return encoder
} }
} }
@@ -145,9 +145,7 @@ public actor ProcessManager {
) )
let process = prepared.process let process = prepared.process
AppLogger(category: "process").debug( logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
children[id] = RunningChild( children[id] = RunningChild(
process: process, process: process,
@@ -214,9 +212,7 @@ public actor ProcessManager {
let stdoutPipe = prepared.stdoutPipe let stdoutPipe = prepared.stdoutPipe
let stderrPipe = prepared.stderrPipe let stderrPipe = prepared.stderrPipe
AppLogger(category: "process").debug( logSpawn(id: id, binary: binary, arguments: arguments, captured: true)
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
// Register and set up the termination hand-off before run() so // Register and set up the termination hand-off before run() so
// a very fast exit is never missed (#50, #52). // a very fast exit is never missed (#50, #52).
@@ -466,6 +462,18 @@ public actor ProcessManager {
) )
} }
private nonisolated func logSpawn(
id: String,
binary: URL,
arguments: [String],
captured: Bool
) {
let prefix = captured ? "spawn(captured)" : "spawn"
AppLogger(category: "process").debug(
"\(prefix) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
}
/// `terminationHandler` can lose a fast-exit race on a loaded host; /// `terminationHandler` can lose a fast-exit race on a loaded host;
/// `waitUntilExit` on a detached thread is the fallback (#50, #52). /// `waitUntilExit` on a detached thread is the fallback (#50, #52).
/// The handler is attached before `run()`; the wait thread starts /// The handler is attached before `run()`; the wait thread starts
@@ -551,7 +559,7 @@ public actor ProcessManager {
// Start a watchdog in case the `readabilityHandler` EOFs never // Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe). // arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in child.finalizeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(nanoseconds: 2_000_000_000)
guard let self else { return } guard let self else { return }
await self.forceKill(id: id) await self.forceKill(id: id)
await self.forceFinalize(id: id) await self.forceFinalize(id: id)
@@ -33,16 +33,16 @@ public struct CalibrationIdentity: Equatable, Sendable {
/// Derive identity from the live wizard basename and the persisted /// Derive identity from the live wizard basename and the persisted
/// original. A non-empty persisted original wins over a `CAL_` live /// original. A non-empty persisted original wins over a `CAL_` live
/// name (Force Quit mid-calibration). /// name (Force Quit mid-calibration). An empty live basename always
/// produces an empty identity a persisted original must never
/// resurrect a target that no longer exists (#83).
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity { public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
if liveBasename.isEmpty && persistedOriginal.isEmpty { guard !liveBasename.isEmpty else {
return CalibrationIdentity(originalBasename: "", calibrationBasename: "") return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
} }
let original: String let original: String
if liveBasename.hasPrefix("CAL_") { if liveBasename.hasPrefix("CAL_") {
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
} else if liveBasename.isEmpty {
original = persistedOriginal
} else { } else {
original = liveBasename original = liveBasename
} }
@@ -79,16 +79,9 @@ public enum PrintcalArgs {
var args: [String] = ["-v", "-e"] var args: [String] = ["-v", "-e"]
if config.noInkLimit { args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
args.append("-I") args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
} args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
if let tac = config.totalInkLimit, tac > 0 { if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)]) args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit { } else if let tac = config.totalInkLimit {
@@ -73,7 +73,11 @@ public actor VerificationHistoryStore {
} }
/// Removes all history and updates disk. /// Removes all history and updates disk.
///
/// Loads the existing history first and propagates any load error so an
/// unparseable file is never overwritten.
public func clear() throws { public func clear() throws {
try load()
try write([]) try write([])
records = [] records = []
} }
@@ -14,7 +14,9 @@ extension TargenConfig {
neutralSteps: preset.neutralSteps, neutralSteps: preset.neutralSteps,
neutralConcentration: preset.neutralConcentration, neutralConcentration: preset.neutralConcentration,
preconditioningProfile: preset.preconditioningProfile, preconditioningProfile: preset.preconditioningProfile,
ofpsHighQuality: preset.ofpsHighQuality == true ? true : nil, // An explicit `false` is preserved distinguishable from a
// missing key; `-G` is only emitted for `true` (#82).
ofpsHighQuality: preset.ofpsHighQuality,
ofpsAdaptation: preset.ofpsAdaptation, ofpsAdaptation: preset.ofpsAdaptation,
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 }, fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
totalInkLimit: preset.totalInkLimit, totalInkLimit: preset.totalInkLimit,
@@ -109,6 +111,51 @@ extension ColprofConfig {
} }
} }
/// User-facing FWA selection for the Stage 4 form, plus the two
/// directions of `colprof_fwa` conversion centralised here so the view
/// models carry no mapping switches of their own (#82).
public enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
case none = "none"
case empty = ""
case D50 = "D50"
case D65 = "D65"
case custom = "custom"
public var displayName: String {
switch self {
case .none: return "None"
case .empty: return "Bare (-f)"
case .D50: return "D50"
case .D65: return "D65"
case .custom: return "Custom .sp"
}
}
/// Preset `colprof_fwa` selection. `nil`/`"none"` map to `.none`,
/// `""` to `.empty`, `D50`/`D65` case-insensitively, and any other
/// string is a custom `.sp` path.
public init(presetValue: String?) {
switch presetValue?.lowercased() {
case nil, "none": self = .none
case "": self = .empty
case "d50": self = .D50
case "d65": self = .D65
default: self = .custom
}
}
/// Selection `colprof_fwa` value. `.custom` returns `customPath`.
public func presetValue(customPath: String) -> String? {
switch self {
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return customPath
}
}
}
extension PageSize { extension PageSize {
/// `"210x297"` custom page parse used by presets (issue #82). /// `"210x297"` custom page parse used by presets (issue #82).
public static func parseCustom(_ raw: String) -> (Double, Double)? { public static func parseCustom(_ raw: String) -> (Double, Double)? {
+4 -3
View File
@@ -11,7 +11,8 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` | | Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
| Default branch | `develop` | | Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` | | M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
| M7 | UAT-ready hardening of the v2.0 wizard paths | | M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79#86) — in flight on `milestone/m8-consolidation` |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 | | Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does ## What it does
@@ -170,11 +171,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
``` ```
develop develop
└── milestone/mN-<slug> # integration only └── milestone/m8-consolidation # integration branch
└── feat/<issue>-<slug> # one issue per branch └── feat/<issue>-<slug> # one issue per branch
``` ```
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. M7 is small; its PRs target `develop` directly. Do not open umbrella "bugfix" branches that mix tickets. Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
## Licence ## Licence
-7
View File
@@ -95,13 +95,6 @@ struct CalibrationView: View {
.frame(minHeight: 80, maxHeight: 120) .frame(minHeight: 80, maxHeight: 120)
} }
} }
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
}
}
} }
.formStyle(.grouped) .formStyle(.grouped)
+20 -23
View File
@@ -25,7 +25,6 @@ final class CalibrationViewModel {
var calibrationLog: [String] = [] var calibrationLog: [String] = []
var isGenerating = false var isGenerating = false
var isComputing = false var isComputing = false
var lastError: String?
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow self.workflow = workflow
@@ -77,10 +76,6 @@ final class CalibrationViewModel {
wizard.basename = identity.calibrationBasename wizard.basename = identity.calibrationBasename
wizard.sessionMode = .calibration wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: colourSpace, colourSpace: colourSpace,
steps: steps, steps: steps,
@@ -92,17 +87,19 @@ final class CalibrationViewModel {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isGenerating = false }
do { do {
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in _ = try await ProcessRunSupport.runLogged(
self?.calibrationLog.append(contentsOf: batch) setRunning: { self.isGenerating = $0 },
}) resetLog: { self.calibrationLog = [] },
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runCalibrationTargen(
config: config, onLogBatch: onLog)
}
self.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.") self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint) self.wizard.go(to: .layOutPrint)
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)", "Calibration target failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -137,15 +134,13 @@ final class CalibrationViewModel {
// "already exists" when the user declines overwrite. We do not // "already exists" when the user declines overwrite. We do not
// silently clobber. // silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) { if FileManager.default.fileExists(atPath: outputURL.path) {
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first." wizard.showNotice(
wizard.showNotice(lastError!, kind: .error) "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
kind: .error
)
return return
} }
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: calBasename, ti3Basename: calBasename,
workingDirectory: cwd, workingDirectory: cwd,
@@ -158,19 +153,21 @@ final class CalibrationViewModel {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isComputing = false }
do { do {
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in let url = try await ProcessRunSupport.runLogged(
self?.calibrationLog.append(contentsOf: batch) setRunning: { self.isComputing = $0 },
}) resetLog: { self.calibrationLog = [] },
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runPrintcal(
config: config, onLogBatch: onLog)
}
self.computedCalURL = url self.computedCalURL = url
self.profile.calibrationFile = url.path self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.") self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration() self.wizard.restoreCalibration()
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)", "Calibration curve computation failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -63,7 +63,8 @@ final class MeasurementWorkflowViewModel {
var rows: [ChartreadRow] = [] var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = [] var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false var showRemoveSheetNotice = false
var lastError: String? /// Stage-local chartread error notice (`#chartreadLastError`, #80).
var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>? private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging // MARK: - Averaging
@@ -185,7 +186,7 @@ final class MeasurementWorkflowViewModel {
isChartreadRunning = true isChartreadRunning = true
chartreadState = .idle chartreadState = .idle
currentPrompt = nil currentPrompt = nil
lastError = nil chartreadNotice = nil
chartreadLog.removeAll() chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass. // Optional: reset rows when starting a fresh first pass.
@@ -227,7 +228,10 @@ final class MeasurementWorkflowViewModel {
case .exit(let code): case .exit(let code):
if code != 0 { if code != 0 {
lastError = "chartread exited with code \(code)" chartreadNotice = Notice(
kind: .error,
text: "chartread exited with code \(code)"
)
} }
case .completed(let canonicalURL): case .completed(let canonicalURL):
@@ -235,7 +239,7 @@ final class MeasurementWorkflowViewModel {
completePass(canonicalURL: canonicalURL) completePass(canonicalURL: canonicalURL)
case .failed(let error): case .failed(let error):
lastError = error.localizedDescription chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
chartreadState = .error chartreadState = .error
isChartreadRunning = false isChartreadRunning = false
} }
@@ -381,7 +385,10 @@ final class MeasurementWorkflowViewModel {
discoverPassSnapshots() discoverPassSnapshots()
wizard.refreshGating() wizard.refreshGating()
} catch { } catch {
lastError = "Could not snapshot pass: \(error.localizedDescription)" chartreadNotice = Notice(
kind: .error,
text: "Could not snapshot pass: \(error.localizedDescription)"
)
} }
} }
@@ -397,30 +404,32 @@ final class MeasurementWorkflowViewModel {
func finishAndAverage() { func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return } guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil finishNotice = nil
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
do { do {
let canonical: URL // No log reset: prior chartread output must be preserved.
let canonical = try await ProcessRunSupport.runLogged(
setRunning: { self.isFinishing = $0 },
resetLog: {},
onLog: { self.chartreadLog.append(contentsOf: $0) }
) { onLog in
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first { if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
canonical = try MeasurementArtefacts.promotePass( return try MeasurementArtefacts.promotePass(
pass: pass, pass: pass,
basename: self.basename, basename: self.basename,
cwd: cwd cwd: cwd
) )
} else { }
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
basename: self.basename, basename: self.basename,
passFiles: self.passSnapshots passFiles: self.passSnapshots
) )
canonical = try await self.environment.runner.runAverage( return try await self.environment.runner.runAverage(
config: config, config: config,
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in onLogBatch: onLog
self?.chartreadLog.append(contentsOf: batch)
}
) )
} }
self.discoverPassSnapshots() self.discoverPassSnapshots()
@@ -465,7 +474,6 @@ final class MeasurementWorkflowViewModel {
) )
} }
} }
self.isFinishing = false
} }
} }
} }
+8
View File
@@ -21,6 +21,14 @@ struct Notice: Identifiable, Equatable {
case .error: return .red case .error: return .red
} }
} }
var accessibilityValue: String {
switch self {
case .info: return "info"
case .warning: return "warning"
case .error: return "error"
}
}
} }
let id = UUID() let id = UUID()
+37 -64
View File
@@ -3,25 +3,6 @@ import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// User-facing FWA selection for the Stage 4 form.
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
case none = "none"
case empty = ""
case D50 = "D50"
case D65 = "D65"
case custom = "custom"
var displayName: String {
switch self {
case .none: return "None"
case .empty: return "Bare (-f)"
case .D50: return "D50"
case .D65: return "D65"
case .custom: return "Custom .sp"
}
}
}
/// 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 @Observable
@@ -50,7 +31,6 @@ final class ProfileWorkflowViewModel {
var isColprofRunning = false var isColprofRunning = false
var colprofLog: [String] = [] var colprofLog: [String] = []
var colprofProgress: String? var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL? 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? var createdGamutURL: URL?
@@ -110,13 +90,7 @@ final class ProfileWorkflowViewModel {
} }
var fwaValue: String? { var fwaValue: String? {
switch fwaSelection { fwaSelection.presetValue(customPath: fwaCustomPath)
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return fwaCustomPath
}
} }
// MARK: - Preset application // MARK: - Preset application
@@ -131,21 +105,16 @@ final class ProfileWorkflowViewModel {
algorithm = config.algorithm algorithm = config.algorithm
quality = config.quality quality = config.quality
intent = config.intent ?? "" intent = config.intent ?? ""
if let fwa = config.fwa { fwaSelection = ColprofFwaSelection(presetValue: config.fwa)
switch fwa.lowercased() { fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : ""
case "none": fwaSelection = .none
case "": fwaSelection = .empty
case "d50": fwaSelection = .D50
case "d65": fwaSelection = .D65
default:
fwaSelection = .custom
fwaCustomPath = fwa
}
}
illuminant = config.illuminant ?? "" illuminant = config.illuminant ?? ""
observer = config.observer ?? "" observer = config.observer ?? ""
inputViewingCond = config.inputViewingCond ?? "" inputViewingCond = config.inputViewingCond ?? ""
outputViewingCond = config.outputViewingCond ?? "" outputViewingCond = config.outputViewingCond ?? ""
profileDescription = ""
copyright = ""
applyCalibration = preset.applyCalibration == true
calibrationFile = preset.calibrationFile ?? ""
} }
/// Stage 4 form values for saving into a custom preset. /// Stage 4 form values for saving into a custom preset.
@@ -195,26 +164,25 @@ final class ProfileWorkflowViewModel {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return } guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig() let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil colprofProgress = nil
lastError = nil
createdProfileURL = nil createdProfileURL = nil
createdGamutURL = nil createdGamutURL = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.isColprofRunning = false }
do { do {
let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in let outcome = try await ProcessRunSupport.runLogged(
guard let self else { return } setRunning: { self.isColprofRunning = $0 },
resetLog: { self.colprofLog = [] },
onLog: { batch in
self.colprofLog.append(contentsOf: batch) self.colprofLog.append(contentsOf: batch)
if let last = batch.last { if let last = batch.last {
self.updateProgress(ColprofProgressClassifier.classify(line: last)) self.updateProgress(ColprofProgressClassifier.classify(line: last))
} }
}) }
) { onLog in
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
var finalProfileURL = url var finalProfileURL = url
@@ -229,26 +197,26 @@ final class ProfileWorkflowViewModel {
} }
// Gamut extraction is best-effort for Stage 5 / M6 viewer. // Gamut extraction is best-effort for Stage 5 / M6 viewer.
var gamutURL: URL?
do { do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL) let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
self?.colprofLog.append(contentsOf: batch) gamutURL = url
}) self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch { } catch {
self.wizard.showNotice( self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)", "Gamut extraction skipped: \(error.localizedDescription)",
kind: .info kind: .info
) )
} }
return (profileURL: finalProfileURL, gamutURL: gamutURL)
self.createdProfileURL = finalProfileURL }
self.createdProfileURL = outcome.profileURL
self.createdGamutURL = outcome.gamutURL
self.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)") self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall) self.wizard.go(to: .verifyInstall)
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)", "Profile creation failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -315,23 +283,28 @@ final class ProfileWorkflowViewModel {
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd) let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL) let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil profcheckReport = nil
profcheckWarning = nil profcheckWarning = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.isProfcheckRunning = false }
do { do {
let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in let outcome = try await ProcessRunSupport.runLogged(
self?.colprofLog.append(contentsOf: batch) setRunning: { self.isProfcheckRunning = $0 },
}) resetLog: {},
self.profcheckReport = report onLog: { self.colprofLog.append(contentsOf: $0) }
) { onLog in
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
var history: [VerificationRecord]?
if let record = self.makeVerificationRecord(from: report) { if let record = self.makeVerificationRecord(from: report) {
let updated = try await self.environment.historyStore.append(record) history = try await self.environment.historyStore.append(record)
self.verificationHistory = updated }
return (report: report, history: history)
}
self.profcheckReport = outcome.report
if let history = outcome.history {
self.verificationHistory = history
self.driftAlert = DriftAlert.compute(from: self.filteredHistory) self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
} }
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable { } catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
+1 -1
View File
@@ -33,7 +33,7 @@ final class SettingsViewModel {
sink.applySettings(settings) sink.applySettings(settings)
savedFlash = true savedFlash = true
Task { Task {
try? await Task.sleep(for: .seconds(1.5)) try? await Task.sleep(nanoseconds: 1_500_000_000)
savedFlash = false savedFlash = false
} }
return true return true
+1
View File
@@ -218,6 +218,7 @@ struct Stage2View: View {
.foregroundStyle(notice.kind == .error .foregroundStyle(notice.kind == .error
? .red : .blue) ? .red : .blue)
.accessibilityIdentifier("printNotificationIcon") .accessibilityIdentifier("printNotificationIcon")
.accessibilityValue(notice.kind.accessibilityValue)
Text(notice.text) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(notice.kind == .error .foregroundStyle(notice.kind == .error
+11 -15
View File
@@ -164,28 +164,22 @@ struct Stage3View: View {
.foregroundStyle(Theme.accent) .foregroundStyle(Theme.accent)
} }
if let lastError = model.lastError { if let notice = model.chartreadNotice {
Text(lastError) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(.red) .foregroundStyle(notice.kind.tint)
.accessibilityIdentifier("chartreadLastError") .accessibilityIdentifier("chartreadLastError")
.accessibilityValue(lastError) .accessibilityValue(notice.text)
} }
controlButtons controlButtons
if !model.chartreadLog.isEmpty { if !model.chartreadLog.isEmpty {
DisclosureGroup("Log") { ProcessLogView(
VStack(alignment: .leading) { lines: model.chartreadLog,
ForEach(model.chartreadLog, id: \.self) { line in containerId: "chartreadLogContainer",
Text(line) logId: "chartreadLog"
.font(.system(.caption, design: .monospaced)) )
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
} }
} }
.padding(16) .padding(16)
@@ -374,6 +368,8 @@ struct Stage3View: View {
Text(notice.text) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(notice.kind == .error ? .red : .green) .foregroundStyle(notice.kind == .error ? .red : .green)
.accessibilityIdentifier("chartreadFinishNotice")
.accessibilityValue(notice.kind.accessibilityValue)
} }
} }
.padding(16) .padding(16)
+5 -18
View File
@@ -166,27 +166,14 @@ struct Stage4View: View {
} }
Spacer() Spacer()
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("colprofLastError")
}
} }
if !model.colprofLog.isEmpty { if !model.colprofLog.isEmpty {
DisclosureGroup("Log") { ProcessLogView(
VStack(alignment: .leading) { lines: model.colprofLog,
ForEach(model.colprofLog, id: \.self) { line in containerId: "colprofLogContainer",
Text(line) logId: "colprofLog"
.font(.system(.caption, design: .monospaced)) )
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
} }
} }
.padding(16) .padding(16)
+11 -7
View File
@@ -206,8 +206,6 @@ final class TargetWorkflowViewModel {
func generateTarget() { func generateTarget() {
guard canGenerate, !targenRunning else { return } guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig() let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false resumedFromTi2 = false
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
@@ -228,7 +226,6 @@ final class TargetWorkflowViewModel {
} catch { } catch {
wizard.showNotice( wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error) "targen failed: \(error.localizedDescription)", kind: .error)
targenRunning = false
} }
} }
} }
@@ -242,7 +239,12 @@ final class TargetWorkflowViewModel {
? UITestHooks.datasetImportURL ? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile() : fileDialogs.selectDatasetFile()
guard let url else { return } guard let url else { return }
importMeasurementDataset(from: url)
}
/// Test seam (issue #80): unit tests pass missing or malformed URLs
/// directly instead of mutating the global environment.
func importMeasurementDataset(from url: URL) {
do { do {
let dataset = try CGATSParser.parse(url: url) let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else { guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
@@ -339,8 +341,6 @@ final class TargetWorkflowViewModel {
func createLayout() { func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return } guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig() let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil printtargResult = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
@@ -380,14 +380,18 @@ final class TargetWorkflowViewModel {
func applyPreset(_ preset: ProfilingPreset) { func applyPreset(_ preset: ProfilingPreset) {
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory) let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
applyTargenForm(targen) applyTargenForm(targen)
// Stage 4 state (incl. calibration) is applied before Stage 2 so
// the layout config receives the preset's calibration path, not
// stale live state (#82).
profile.applyPreset(preset)
let printtarg = PrinttargConfig( let printtarg = PrinttargConfig(
preset: preset, preset: preset,
basename: wizard.basename, basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory, workingDirectory: wizard.effectiveWorkingDirectory,
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil calibrationFile: profile.applyCalibration && !profile.calibrationFile.isEmpty
? profile.calibrationFile : nil
) )
applyPrinttargForm(printtarg) applyPrinttargForm(printtarg)
profile.applyPreset(preset)
selectedPresetID = preset.id selectedPresetID = preset.id
} }
+1 -1
View File
@@ -201,7 +201,7 @@ final class WizardViewModel {
self.notice = notice self.notice = notice
if let delay = notice.autoHideAfter { if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in noticeDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay)) try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id { if self?.notice?.id == notice.id {
self?.notice = nil self?.notice = nil
@@ -0,0 +1,94 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("ArgsBuilder")
struct ArgsBuilderTests {
// MARK: - option
@Test("option: nil emits nothing")
func optionNil() {
#expect(ArgsBuilder.option("-f", nil) == [])
}
@Test("option: present value emits flag and value verbatim")
func optionPresent() {
#expect(ArgsBuilder.option("-f", "abc") == ["-f", "abc"])
#expect(ArgsBuilder.option("-f", "") == ["-f", ""])
#expect(ArgsBuilder.option("-f", " padded ") == ["-f", " padded "])
}
// MARK: - optionIfNonEmpty
@Test("optionIfNonEmpty: nil and empty emit nothing")
func optionIfNonEmptyNilEmpty() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", nil) == [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "") == [])
}
@Test("optionIfNonEmpty: whitespace-only emits nothing")
func optionIfNonEmptyWhitespace() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", " ") == [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " \t\n ") == [])
}
@Test("optionIfNonEmpty: trims surrounding whitespace")
func optionIfNonEmptyTrims() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", " label ") == ["-d", "label"])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n") == ["-d", "cal.cal"])
}
// MARK: - optionUnlessApprox
@Test("optionUnlessApprox: nil emits nothing")
func optionUnlessApproxNil() {
#expect(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50) == [])
}
@Test("optionUnlessApprox: exact skip value emits nothing")
func optionUnlessApproxExactSkip() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50) == [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0) == [])
}
@Test("optionUnlessApprox: within epsilon emits nothing")
func optionUnlessApproxWithinEpsilon() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50) == [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0) == [])
}
@Test("optionUnlessApprox: outside epsilon emits flag")
func optionUnlessApproxOutsideEpsilon() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50) == ["-N", "0.75"])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0) == ["-V", "1.50"])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50) == ["-N", "0.50"])
}
@Test("optionUnlessApprox: POSIX formatting is locale-stable")
func optionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal.
#expect(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0) == ["-p", "1234.50"])
#expect(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0) == ["-p", "2.00"])
}
@Test("optionUnlessApprox: custom epsilon and format honoured")
func optionUnlessApproxCustom() {
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01) == [])
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f") == ["-x", "1.5"])
}
// MARK: - flag
@Test("flag: true emits the bare flag")
func flagTrue() {
#expect(ArgsBuilder.flag("-G", when: true) == ["-G"])
#expect(ArgsBuilder.flag("-r", when: true) == ["-r"])
}
@Test("flag: false emits nothing")
func flagFalse() {
#expect(ArgsBuilder.flag("-G", when: false) == [])
#expect(ArgsBuilder.flag("-r", when: false) == [])
}
}
@@ -5,13 +5,13 @@ import Testing
@Suite("ArgyllRunner Calibration") @Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests { struct ArgyllRunnerCalibrationTests {
private func makeRunner() -> ArgyllRunner { private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() .deletingLastPathComponent()
.deletingLastPathComponent() .deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin") .appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner( return ArgyllRunner(
processManager: .shared, processManager: processManager,
binaryResolver: BinaryResolver(overrideDir: binDir) binaryResolver: BinaryResolver(overrideDir: binDir)
) )
} }
@@ -41,6 +41,35 @@ struct ArgyllRunnerCalibrationTests {
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 calibrationTargenProcessId() async throws {
let testRoot = try makeTestDir()
let pm = ProcessManager()
let runner = makeRunner(processManager: pm)
let events = pm.events()
// Subscribed before spawn; the exit event is emitted before
// runCalibrationTargen returns, so this always terminates.
let sawExit = Task {
for await event in events {
guard event.id == "targen_CAL_foo" else { continue }
if case .exit = event { return true }
}
return false
}
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
basename: "foo",
workingDirectory: testRoot
)
let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_foo.ti1")
#expect(await sawExit.value)
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal captured run creates .cal") @Test("printcal captured run creates .cal")
func printcalProducesCal() async throws { func printcalProducesCal() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
@@ -59,10 +88,28 @@ struct ArgyllRunnerCalibrationTests {
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("printcal failure throws printcalFailed") @Test("printcal failure throws toolFailed")
func printcalFailureThrows() async throws { func printcalFailureThrows() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let runner = makeRunner() defer { try? FileManager.default.removeItem(at: testRoot) }
// Per-test mock printcal that always fails no global
// environment mutation, no shared fixture changes.
let binDir = try makeTestDir()
defer { try? FileManager.default.removeItem(at: binDir) }
let mockURL = binDir.appendingPathComponent("printcal")
try """
#!/bin/sh
echo "printcal mock failure" >&2
exit 1
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)
)
let output = testRoot.appendingPathComponent("CAL_demo.cal") let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "CAL_demo", ti3Basename: "CAL_demo",
@@ -70,12 +117,9 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output outputURL: output
) )
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1) await #expect(throws: ArgyllRunnerError.toolFailed(
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") } tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
await #expect(throws: (any Error).self) {
_ = try await runner.runPrintcal(config: config) _ = try await runner.runPrintcal(config: config)
} }
try? FileManager.default.removeItem(at: testRoot)
} }
} }
@@ -33,7 +33,7 @@ struct ArgyllRunnerColprofTests {
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: .shared, processManager: ProcessManager(),
binaryResolver: BinaryResolver(overrideDir: binDir) binaryResolver: BinaryResolver(overrideDir: binDir)
) )
@@ -49,4 +49,32 @@ struct ArgyllRunnerColprofTests {
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("Failing colprof throws toolFailed with code and logs")
func colprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let mockURL = dir.appendingPathComponent("colprof")
try """
#!/bin/sh
echo "colprof broke" >&2
exit 4
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)
)
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"])) {
try await runner.runColprof(config: config)
}
}
} }
@@ -0,0 +1,161 @@
import Foundation
import Testing
@testable import ICCeryCore
/// Focused contracts for the shared `runStreamingTool` loop (#79).
///
/// Every test uses a per-test temporary directory, unique basenames,
/// and a fresh `ProcessManager` no shared UI fixture scripts and no
/// process-environment mutation.
@Suite("ArgyllRunner streaming loop contracts")
struct ArgyllRunnerStreamingLoopTests {
private func makeTempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("runner-loop-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func writeMock(_ name: String, _ body: String, in dir: URL) throws {
let url = dir.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
private func makeRunner(binDir: URL) -> ArgyllRunner {
ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
}
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines")
func nonZeroExitThrowsToolFailed() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
echo "Generating patches..."
echo "targen: too few patches" >&2
exit 3
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "fail", workingDirectory: dir)
do {
_ = try await runner.runTargen(config: config)
Issue.record("Expected toolFailed")
} catch let error as ArgyllRunnerError {
guard case .toolFailed(let tool, let code, let logs) = error else {
Issue.record("Expected toolFailed, got \(error)")
return
}
#expect(tool == "targen")
#expect(code == 3)
#expect(logs.contains("Generating patches..."))
#expect(logs.contains("targen: too few patches"))
}
}
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path")
func zeroExitMissingArtefact() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
echo "done but wrote nothing"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let expectedPath = dir.appendingPathComponent("gone.ti1").path
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "gone", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) {
try await runner.runTargen(config: config)
}
}
@Test("Immediate exit after one stdout line still delivers the line and succeeds")
func immediateExitDeliversLine() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
echo "only line"
touch "$last.ti1"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "quick", workingDirectory: dir)
let holder = LogHolder()
let url = try await runner.runTargen(config: config) { batch in
holder.append(batch)
}
#expect(url.lastPathComponent == "quick.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("only line"))
}
@Test("colprof unterminated progress fragment reaches onLogBatch before exit")
func colprofPartialLineFlush() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
// The fragment is printed without a newline, then the mock sleeps
// past the 500 ms partial-line flush interval before writing the
// artefact and exiting so the tail is delivered mid-run.
try writeMock("colprof", """
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
printf 'Doing gamut mapping'
sleep 2
touch "$last.icc"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = ColprofConfig(basename: "frag", workingDirectory: dir)
let holder = LogHolder()
let url = try await runner.runColprof(config: config) { batch in
holder.append(batch)
}
#expect(url.lastPathComponent == "frag.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("Doing gamut mapping"))
}
@Test("toolFailed maps each tool to its user-facing description",
arguments: [
(tool: "chartread", expected: "Chartread failed: boom"),
(tool: "average", expected: "Averaging failed: boom"),
(tool: "colprof", expected: "Profile creation failed: boom"),
(tool: "printcal", expected: "Calibration curve computation failed: boom"),
(tool: "applycal", expected: "Apply calibration failed: boom"),
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
(tool: "profcheck", expected: "Profile verification failed: boom"),
])
func toolDescriptions(tool: String, expected: String) {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
#expect(error.errorDescription == expected)
}
@Test("toolFailed falls back to a generic description for unmapped tools and empty logs")
func genericFallbacks() {
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
#expect(unknown.errorDescription == "Process exited with code 7")
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
#expect(emptyLogs.errorDescription
== "Profile creation failed: exited with code 2")
}
}
+83 -7
View File
@@ -125,26 +125,102 @@ struct ArtefactFilesTests {
@Suite("ArtefactProbe profile resolve") @Suite("ArtefactProbe profile resolve")
struct ArtefactProbeProfileTests { struct ArtefactProbeProfileTests {
@Test("basename probe prefers .icm") private func makeDir() throws -> URL {
func icmWins() throws {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)") .appendingPathComponent("probe-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: Basename probe matrix (#69)
@Test("basename probe: only .icc exists")
func onlyIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path)
}
@Test("basename probe: only .icm exists")
func onlyIcm() throws {
let dir = try makeDir()
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path)
}
@Test("basename probe prefers .icm")
func icmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm")) let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir) let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
#expect(url?.pathExtension == "icm") #expect(url?.path == icm.path)
}
@Test("basename probe: neither exists returns nil")
func neitherExists() throws {
let dir = try makeDir()
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil)
}
// MARK: Explicit URL matrix (#69 / #83)
@Test("explicit existing .icc wins even when .icm exists")
func explicitIccWins() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
}
@Test("explicit existing .icm wins even when .icc exists")
func explicitIcmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path)
} }
@Test("explicit missing .icc flips to sibling .icm") @Test("explicit missing .icc flips to sibling .icm")
func flipExtension() throws { func flipExtension() throws {
let dir = FileManager.default.temporaryDirectory let dir = try makeDir()
.appendingPathComponent("probe-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm") let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm) try Data("icm".utf8).write(to: icm)
let resolved = ArtefactProbe.resolveProfile(icc) let resolved = ArtefactProbe.resolveProfile(icc)
#expect(resolved.path == icm.path) #expect(resolved.path == icm.path)
} }
@Test("explicit missing .icm flips to sibling .icc")
func flipToIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icc".utf8).write(to: icc)
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path)
}
@Test("explicit missing both returns the original URL")
func missingBoth() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
}
@Test("unrelated extension is never rewritten")
func unrelatedExtension() throws {
let dir = try makeDir()
let mpp = dir.appendingPathComponent("job.mpp")
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path)
let txt = dir.appendingPathComponent("job.txt")
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path)
}
} }
@@ -0,0 +1,78 @@
import Foundation
import Testing
@testable import ICCeryCore
/// Issue #83 canonical `CAL_` / original-stem pairing.
@Suite("CalibrationIdentity")
struct CalibrationIdentityTests {
@Test("live foo, no persisted")
func livePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live foo ignores stale persisted")
func livePlainIgnoresPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live CAL_foo, persisted foo")
func liveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live CAL_foo, empty persisted strips prefix")
func liveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("persisted original wins over CAL_ live")
func persistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
#expect(id.originalBasename == "bar")
#expect(id.calibrationBasename == "CAL_bar")
}
@Test("empty live yields empty identity even with persisted original")
func emptyLiveWithPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
}
@Test("empty live, empty persisted")
func emptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
}
@Test("prefix is idempotent on already-prefixed input")
func alreadyPrefixed() {
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "CAL_foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("prefix never invents a name from empty input")
func prefixEmpty() {
#expect(CalibrationIdentity.prefix("").isEmpty)
#expect(CalibrationIdentity.strip("foo") == "foo")
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
}
@Test("runner process id for a calibration targen is targen_CAL_*")
func processIdMatches() {
let cal = CalibrationIdentity.prefix("foo")
#expect(ProcessID.targen(cal) == "targen_CAL_foo")
}
}
+31 -39
View File
@@ -9,7 +9,17 @@ struct JSONFileStoreTests {
.appendingPathComponent("json-store-\(UUID().uuidString).json") .appendingPathComponent("json-store-\(UUID().uuidString).json")
} }
@Test("Corrupt file with replaceWithDefault returns default") @Test("Missing file returns default")
func missingFileDefaults() throws {
let store = JSONFileStore<AppSettings>(
fileURL: tempURL(),
corrupt: .throwCorrupt,
defaultValue: { .default }
)
#expect(try store.load() == .default)
}
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
func corruptDefaults() 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)
@@ -19,7 +29,8 @@ struct JSONFileStoreTests {
defaultValue: { .default } defaultValue: { .default }
) )
#expect(try store.load() == .default) #expect(try store.load() == .default)
#expect(FileManager.default.fileExists(atPath: url.path)) let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "{ not json")
} }
@Test("Corrupt file with throwCorrupt throws and leaves bytes") @Test("Corrupt file with throwCorrupt throws and leaves bytes")
@@ -50,43 +61,24 @@ struct JSONFileStoreTests {
let text = try String(contentsOf: url, encoding: .utf8) let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains("\n")) #expect(text.contains("\n"))
#expect(text.contains("\"delta_e_good_max\"")) #expect(text.contains("\"delta_e_good_max\""))
// Lexical key sorting: ascending order of top-level keys.
let keys = [
"ask_before_overwrite_profile",
"calibration_stale_days",
"custom_presets",
"default_install_location",
"delta_e_good_max",
"delta_e_warning_max",
"enable_i1pro2_leds",
"open_color_panel_after_install",
]
var lastIndex = text.startIndex
for key in keys {
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
Issue.record("missing or out-of-order key \(key)")
return
}
lastIndex = range.upperBound
} }
} }
@Suite("CalibrationIdentity")
struct CalibrationIdentityTests {
@Test("live foo, no persisted")
func livePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live CAL_foo, persisted foo")
func liveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live CAL_foo, empty persisted strips prefix")
func liveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("persisted original wins")
func persistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
#expect(id.originalBasename == "bar")
#expect(id.calibrationBasename == "CAL_bar")
}
@Test("empty live does not invent a name")
func emptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
}
} }
@@ -159,6 +159,51 @@ struct ChartreadRowTests {
#expect(row.patchCount == 1) #expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51) #expect(row.patches[0].measured.lab?.l == 51)
} }
@Test("Decodes a row carrying both XYZ and Lab arrays")
func decodeXYZAndLab() throws {
let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [
{"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40],
"measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}}
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured
#expect(measured.xyz == CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
#expect(measured.lab == CIELab(l: 63.4, a: 2.5, b: -8.2))
}
@Test("XYZColor/CIEXYZ encode as an unkeyed three-number array")
func xyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
#expect(value as? [Double] == [1.5, 2.5, 3.5])
}
}
@Test("LabColor/CIELab encode as an unkeyed three-number array")
func labWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
#expect(value as? [Double] == [50, -1, 2])
}
}
@Test("PatchColor keeps the XYZ and Lab keys over unkeyed arrays")
func patchColorKeys() throws {
let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any]
#expect(object?["XYZ"] as? [Double] == [10, 20, 30])
#expect(object?["Lab"] as? [Double] == [55, 1, -2])
#expect(object?["spectral"] == nil)
}
} }
@Suite("ColourMath") @Suite("ColourMath")
+206
View File
@@ -287,4 +287,210 @@ struct PresetMappingTests {
#expect(back.colprofFwa == "D50") #expect(back.colprofFwa == "D50")
#expect(back.greySteps == nil) #expect(back.greySteps == nil)
} }
@Test("Full preset round-trips through all three configs with every field asserted")
func fullRoundTrip() {
let preset = ProfilingPreset(
id: "custom-full",
name: "Full",
description: "All fields",
colourSpace: "cmyk",
patchCount: 1500,
whitePatches: 6,
blackPatches: 8,
greySteps: 9,
singleChannelSteps: 7,
neutralSteps: 4,
neutralConcentration: 0.7,
preconditioningProfile: "/tmp/pre.icm",
ofpsHighQuality: true,
ofpsAdaptation: 0.2,
fullSpreadAlgorithm: "R",
totalInkLimit: 280,
darkEmphasis: 1.3,
devicePower: 1.2,
instrument: "p3",
pageSize: "250x300",
bitDepth: 16,
dpi: 360,
randomSeed: 42,
noRandomize: false,
calibrationFile: "/tmp/a.cal",
applyCalibration: true,
colprofAlgorithm: "x",
colprofQuality: "u",
colprofIntent: "p",
colprofFwa: "D65",
colprofIlluminant: "D65",
colprofObserver: "1931_2",
colprofInputViewingCond: "D50_2",
colprofOutputViewingCond: "D65_2"
)
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
#expect(targen.colourSpace == .cmyk)
#expect(targen.patchCount == 1500)
#expect(targen.whitePatches == 6)
#expect(targen.blackPatches == 8)
#expect(targen.greySteps == 9)
#expect(targen.singleChannelSteps == 7)
#expect(targen.neutralSteps == 4)
#expect(targen.neutralConcentration == 0.7)
#expect(targen.preconditioningProfile == "/tmp/pre.icm")
#expect(targen.ofpsHighQuality == true)
#expect(targen.ofpsAdaptation == 0.2)
#expect(targen.fullSpreadAlgorithm == .uniformRandom)
#expect(targen.totalInkLimit == 280)
#expect(targen.darkEmphasis == 1.3)
#expect(targen.devicePower == 1.2)
let printtarg = PrinttargConfig(
preset: preset,
basename: "j",
workingDirectory: nil,
calibrationFile: preset.calibrationFile
)
#expect(printtarg.instrument == .p3)
#expect(printtarg.pageSize == .custom)
#expect(printtarg.customPageWidth == 250)
#expect(printtarg.customPageHeight == 300)
#expect(printtarg.bitDepth == .sixteen)
#expect(printtarg.dpi == 360)
#expect(printtarg.layoutOrder == .customSeed)
#expect(printtarg.customSeed == 42)
#expect(printtarg.calibrationFile == "/tmp/a.cal")
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
#expect(colprof.algorithm == "x")
#expect(colprof.quality == "u")
#expect(colprof.intent == "p")
#expect(colprof.fwa == "D65")
#expect(colprof.illuminant == "D65")
#expect(colprof.observer == "1931_2")
#expect(colprof.inputViewingCond == "D50_2")
#expect(colprof.outputViewingCond == "D65_2")
let back = ProfilingPreset(
id: preset.id,
name: preset.name,
description: preset.description,
targen: targen,
printtarg: printtarg,
colprof: colprof,
calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration
)
#expect(back == preset)
}
@Test("Every full-spread algorithm round-trips", arguments: [
("ofps", FullSpreadAlgorithm.ofps),
("t", .target),
("r", .random),
("R", .uniformRandom),
("q", .quasiRandom),
("Q", .uniformQuasiRandom),
("i", .invertedQuasiRandom),
("I", .invertedUniformQuasiRandom)
])
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.fullSpreadAlgorithm = value
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
if expected == .ofps {
// ofps is the default no flag emitted, stored value is nil.
#expect(cfg.fullSpreadAlgorithm == nil)
} else {
#expect(cfg.fullSpreadAlgorithm == expected)
}
let back = ProfilingPreset(
id: "x", name: "n", description: "",
targen: cfg,
printtarg: PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
),
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
calibrationFile: nil,
applyCalibration: nil
)
#expect(back.fullSpreadAlgorithm == value)
}
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil")
func ofpsHighQualityFalse() {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.ofpsHighQuality = false
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(cfg.ofpsHighQuality == false)
preset.ofpsHighQuality = nil
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(nilCfg.ofpsHighQuality == nil)
}
@Test("noRandomize/seed layout mapping rules", arguments: [
(true, nil, LayoutOrder.raster, 1),
(true, 7, .raster, 7),
(false, nil, .deterministic, 1),
(false, 1, .deterministic, 1),
(nil, 1, .deterministic, 1),
(false, 5, .customSeed, 5)
] as [(Bool?, Int?, LayoutOrder, Int)])
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.noRandomize = noRandomize
preset.randomSeed = seed
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
#expect(cfg.layoutOrder == layout)
#expect(cfg.customSeed == expectedSeed)
}
@Test("Custom page fallback matrix", arguments: [
("250x300", PageSize.custom, 250.0, 300.0),
("50x50", .custom, 50.0, 50.0),
("foo", .a4, 210.0, 297.0),
("30x40", .a4, 210.0, 297.0),
("210x", .a4, 210.0, 297.0)
] as [(String, PageSize, Double, Double)])
func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.pageSize = raw
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
#expect(cfg.pageSize == page)
#expect(cfg.customPageWidth == w)
#expect(cfg.customPageHeight == h)
}
@Test("FWA preset value → selection matrix", arguments: [
(nil, ColprofFwaSelection.none),
("none", .none),
("NONE", .none),
("", .empty),
("D50", .D50),
("d50", .D50),
("D65", .D65),
("d65", .D65),
("/tmp/fwa.sp", .custom)
] as [(String?, ColprofFwaSelection)])
func fwaToSelection(raw: String?, expected: ColprofFwaSelection) {
#expect(ColprofFwaSelection(presetValue: raw) == expected)
}
@Test("FWA selection → preset value matrix", arguments: [
(ColprofFwaSelection.none, nil),
(.empty, ""),
(.D50, "D50"),
(.D65, "D65"),
(.custom, "/tmp/fwa.sp")
] as [(ColprofFwaSelection, String?)])
func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) {
#expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected)
}
} }
@@ -0,0 +1,127 @@
import Testing
import Foundation
@testable import ICCeryCore
@testable import ICCery
/// Issue #82 preset application through the live view models, under an
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
@Suite("PresetViewModelMapping")
@MainActor
struct PresetViewModelMappingTests {
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
let env = try TestAppEnvironment.make()
return (env, TargetWorkflowViewModel(environment: env.environment))
}
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path")
func nilFwaClearsCustomPath() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
var customPreset = ProfilingPreset(
id: "c-fwa", name: "FWA", patchCount: 800,
colprofFwa: "/tmp/fwa.sp"
)
vm.applyPreset(customPreset)
#expect(vm.profile.fwaSelection == .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp")
customPreset.colprofFwa = nil
vm.applyPreset(customPreset)
#expect(vm.profile.fwaSelection == .none)
#expect(vm.profile.fwaCustomPath == "")
#expect(vm.profile.fwaValue == nil)
}
@Test("Custom FWA preset path survives the round-trip to colprof_fwa")
func customFwaRoundTrip() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
let preset = ProfilingPreset(
id: "c-fwa2", name: "FWA2", patchCount: 800,
colprofFwa: "/tmp/other.sp"
)
vm.applyPreset(preset)
#expect(vm.profile.fwaSelection == .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp")
#expect(vm.profile.fwaValue == "/tmp/other.sp")
}
@Test("Preset calibration reaches Stage 2 instead of stale live state")
func presetCalibrationReachesStage2() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
// Stale live state must not leak into the preset-applied layout.
vm.profile.applyCalibration = true
vm.profile.calibrationFile = "/tmp/stale.cal"
let preset = ProfilingPreset(
id: "c-cal", name: "Cal", patchCount: 800,
calibrationFile: "/tmp/preset.cal",
applyCalibration: true
)
vm.applyPreset(preset)
#expect(vm.profile.applyCalibration)
#expect(vm.profile.calibrationFile == "/tmp/preset.cal")
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal")
}
@Test("Preset with calibration disabled clears Stage 2 calibration")
func disabledCalibrationClearsStage2() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
vm.profile.applyCalibration = true
vm.profile.calibrationFile = "/tmp/stale.cal"
let preset = ProfilingPreset(
id: "c-nocal", name: "NoCal", patchCount: 800,
calibrationFile: "/tmp/preset.cal",
applyCalibration: nil
)
vm.applyPreset(preset)
#expect(!vm.profile.applyCalibration)
#expect(vm.buildPrinttargConfig().calibrationFile == nil)
}
@Test("Preset Stage 1/2 form fields apply to the live form")
func formFieldsApply() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
var preset = ProfilingPreset(
id: "c-form", name: "Form",
colourSpace: "cmyk", patchCount: 1500,
whitePatches: 6,
blackPatches: 8,
greySteps: 9,
fullSpreadAlgorithm: "r",
pageSize: "250x300",
dpi: 150
)
vm.applyPreset(preset)
#expect(vm.colourSpace == .cmyk)
#expect(vm.effectivePatchCount == 1500)
#expect(vm.whitePatches == 6)
#expect(vm.blackPatches == 8)
#expect(vm.greyStepsEnabled && vm.greySteps == 9)
#expect(vm.algorithm == .random)
#expect(vm.tiffDpi == 150)
#expect(vm.pageSize == .custom)
#expect(vm.customPageW == 250 && vm.customPageH == 300)
#expect(vm.selectedPresetID == "c-form")
// Disabled advanced controls stay nil in the snapshot, not
// numeric sentinels.
preset.greySteps = nil
vm.applyPreset(preset)
#expect(!vm.greyStepsEnabled)
#expect(vm.buildTargenConfig().greySteps == nil)
}
}
@@ -44,6 +44,29 @@ struct PrintcalArgsTests {
]) ])
} }
@Test("Whitespace-only previous calibration path emits no -a")
func whitespacePreviousCal() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " \n\t "
)
let args = try PrintcalArgs.build(config: config)
#expect(!args.contains("-a"))
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
}
@Test("Previous calibration path is trimmed before emission")
func previousCalTrimmed() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " /tmp/old.cal "
)
let args = try PrintcalArgs.build(config: config)
#expect(args[args.firstIndex(of: "-a")! + 1] == "/tmp/old.cal")
}
@Test("Rejects invalid per-channel limit") @Test("Rejects invalid per-channel limit")
func rejectsBadChannelLimit() { func rejectsBadChannelLimit() {
let config = PrintcalConfig( let config = PrintcalConfig(
+20 -2
View File
@@ -131,6 +131,23 @@ struct PrinttargArgsTests {
#expect(!args.contains("-I")) #expect(!args.contains("-I"))
} }
@Test("Whitespace-only label emits no -d; whitespace-only calibration emits no -K/-I")
func whitespaceOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " \n ", calFile: " \t "))
#expect(!args.contains("-d"))
#expect(!args.contains("-K"))
#expect(!args.contains("-I"))
}
@Test("Label and calibration values are trimmed before emission")
func trimmedOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " My Label ", calFile: " /tmp/a.cal "))
#expect(args[args.firstIndex(of: "-d")! + 1] == "My Label")
#expect(args[args.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
}
@Test("Unsafe basename throws") @Test("Unsafe basename throws")
func unsafeBasename() { func unsafeBasename() {
#expect(throws: PathSecurity.Error.self) { #expect(throws: PathSecurity.Error.self) {
@@ -349,7 +366,7 @@ struct ArgyllRunnerPrinttargTests {
} }
} }
@Test("Non-zero exit throws processFailed and stays on stage") @Test("Non-zero exit throws toolFailed and stays on stage")
func failure() async throws { func failure() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
@@ -360,7 +377,8 @@ 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 #expect(throws: ArgyllRunnerError.toolFailed(
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))
} }
@@ -58,6 +58,59 @@ struct ProcessManagerTests {
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true } func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
} }
/// Subscribes synchronously (registration happens inside `events()`)
/// then records every event for `id` until the task is cancelled.
/// Unlike `collect`, observation continues past `.exit` so tests can
/// prove exactly-once exit emission.
private func observe(
_ manager: ProcessManager,
id: String,
into box: Box
) -> Task<Void, Never> {
let stream = manager.events()
return Task {
for await event in stream {
guard event.id == id else { continue }
box.append(event)
}
}
}
private func exitCount(in box: Box) -> Int {
box.events.filter { if case .exit = $0 { return true }; return false }.count
}
private func waitForExit(in box: Box, timeout: TimeInterval = 10) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if exitCount(in: box) > 0 { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
private func waitForFile(_ url: URL, timeout: TimeInterval = 5) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
private func waitForRunning(
_ manager: ProcessManager,
id: String,
timeout: TimeInterval = 5
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await manager.isRunning(id) { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
// MARK: - Tests // MARK: - Tests
@Test func streamsStdoutAndEmitsExit() async throws { @Test func streamsStdoutAndEmitsExit() async throws {
@@ -214,6 +267,145 @@ struct ProcessManagerTests {
try await pm.sendStdin(id: "nope", text: "d\n") try await pm.sendStdin(id: "nope", text: "d\n")
} }
} }
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
let pm = ProcessManager()
let marker = Self.fixtureDir
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
let bin = try script(
"partial-row.sh",
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":9}'\ntouch \"$1\"\nsleep 30\n"
)
let box = Box()
let observer = observe(pm, id: "t11", into: box)
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
#expect(await waitForFile(marker))
// Retry the flush so the pipe-ingest task can win the actor race
// on a loaded host; the first successful flush emits the row.
var flushed = false
for _ in 0..<50 {
await pm.flushPartialLine(id: "t11")
if box.events.contains(where: { if case .jsonRow = $0 { return true }; return false }) {
flushed = true
break
}
try await Task.sleep(for: .milliseconds(20))
}
#expect(flushed)
await pm.kill(id: "t11")
#expect(await waitForExit(in: box))
observer.cancel()
let events = box.events
let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
#expect(rows == ["{\"row\":9}"])
// Prefixed tails must not leak into stdout, even via finalize.
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
#expect(exitCount(in: box) == 1)
}
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
let pm = ProcessManager()
let bin = try script(
"row-tail.sh",
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":42}'\n"
)
let box = Box()
let observer = observe(pm, id: "t12", into: box)
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
#expect(await waitForExit(in: box))
observer.cancel()
let events = box.events
let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
#expect(rows == ["{\"row\":42}"])
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
let rowIndex = events.firstIndex {
if case .jsonRow = $0 { return true }; return false
}
let exitIndexes = events.indices.filter {
if case .exit = events[$0] { return true }; return false
}
#expect(exitIndexes.count == 1)
if let rowIndex, let exitIndex = exitIndexes.first {
#expect(rowIndex < exitIndex)
} else {
Issue.record("expected a jsonRow before the exit event")
}
}
@Test func fastStreamingExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager()
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
let box = Box()
let observer = observe(pm, id: "t13", into: box)
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
#expect(await waitForExit(in: box))
// The grace window must outlast the 2 s finalize watchdog so a
// duplicate emission from it would be observed.
try await Task.sleep(for: .milliseconds(2500))
observer.cancel()
#expect(box.events == [.exit(id: "t13", code: 0)])
}
@Test func fastCapturedExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager()
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
let box = Box()
let observer = observe(pm, id: "t14", into: box)
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
#expect(result.exitCode == 7)
// Both the termination handler and the waitUntilExit watchdog
// resume the same box; give the slower path time to fire.
try await Task.sleep(for: .milliseconds(500))
observer.cancel()
#expect(box.events == [.exit(id: "t14", code: 7)])
}
@Test func capturedRunSetsArgyllNotInteractive() async throws {
let pm = ProcessManager()
let bin = try script(
"cap-env.sh",
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
)
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
#expect(result.stdout == "ANI=1\n")
}
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
let pm = ProcessManager()
let marker = Self.fixtureDir
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
let slowBin = try script("mixed-slow.sh", "#!/bin/sh\nsleep 30\n")
let capBin = try script("mixed-cap.sh", "#!/bin/sh\ntouch \"$1\"\nsleep 30\n")
let streamBox = Box()
let capBox = Box()
let streamObserver = observe(pm, id: "t16", into: streamBox)
let capObserver = observe(pm, id: "t17", into: capBox)
try await pm.runStreaming(id: "t16", binary: slowBin, arguments: [])
let capTask = Task {
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
}
#expect(await waitForFile(marker))
#expect(await waitForRunning(pm, id: "t16"))
#expect(await waitForRunning(pm, id: "t17"))
#expect(await pm.killAll() == 2)
_ = try await capTask.value
#expect(await waitForExit(in: streamBox))
#expect(await waitForExit(in: capBox))
// Grace window outlasts the streaming finalize watchdog.
try await Task.sleep(for: .milliseconds(2500))
streamObserver.cancel()
capObserver.cancel()
#expect(!(await pm.isRunning("t16")))
#expect(!(await pm.isRunning("t17")))
#expect(exitCount(in: streamBox) == 1)
#expect(exitCount(in: capBox) == 1)
}
} }
@Suite("ProcessLineDecoder") @Suite("ProcessLineDecoder")
@@ -0,0 +1,67 @@
import Foundation
import Testing
@testable import ICCery
/// Direct contracts for the shared logged-run helper (issue #80).
///
/// `runLogged` owns the running-flag transition (`false true false`)
/// and the log-reset decision; these tests pin both sides of the
/// contract plus the coalesced `@MainActor` log hop.
@Suite("ProcessRunSupport runLogged")
@MainActor
struct ProcessRunSupportTests {
private struct SentinelError: Error {}
@Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved")
func successTransitions() async throws {
var running: [Bool] = []
var resets = 0
var received: [String] = []
let result = try await ProcessRunSupport.runLogged(
setRunning: { running.append($0) },
resetLog: { resets += 1 },
onLog: { batch in
MainActor.assertIsolated()
received.append(contentsOf: batch)
}
) { onLog in
onLog(["alpha", "beta"])
return 42
}
#expect(result == 42)
#expect(running == [true, false])
#expect(resets == 1)
// The sink hops back through a main-actor Task; yield until the
// coalesced batch lands.
for _ in 0..<200 where received.isEmpty {
try await Task.sleep(for: .milliseconds(10))
}
#expect(received == ["alpha", "beta"])
}
@Test("Failure: running still transitions [true, false], log resets once, error is rethrown")
func failureTransitions() async throws {
var running: [Bool] = []
var resets = 0
do {
_ = try await ProcessRunSupport.runLogged(
setRunning: { running.append($0) },
resetLog: { resets += 1 },
onLog: { _ in }
) { _ -> Int in
throw SentinelError()
}
Issue.record("Expected runLogged to rethrow")
} catch is SentinelError {
// Expected path.
}
#expect(running == [true, false])
#expect(resets == 1)
}
}
+22
View File
@@ -93,6 +93,28 @@ struct SettingsStoreTests {
#expect(!FileManager.default.fileExists(atPath: url.path)) #expect(!FileManager.default.fileExists(atPath: url.path))
} }
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var valid = AppSettings.default
valid.deltaEGoodMax = 1.5
try store.save(valid)
let originalBytes = try Data(contentsOf: url)
var fired = false
let token = NotificationCenter.default.addObserver(
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) }
var invalid = AppSettings.default
invalid.deltaEGoodMax = 9.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
#expect(try Data(contentsOf: url) == originalBytes)
#expect(!fired)
#expect(store.load() == valid)
}
@Test func savePostsNotification() async throws { @Test func savePostsNotification() async throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
+33 -3
View File
@@ -162,6 +162,34 @@ struct TargenArgsTests {
#expect(!args.contains("-p")) #expect(!args.contains("-p"))
} }
@Test("Whitespace-only preconditioning profile emits no -c")
func whitespacePreconditioner() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
preconditioningProfile: " \n\t ",
basename: "ws_pre"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("-c"))
}
@Test("Preconditioning profile is trimmed before emission")
func preconditionerTrimmed() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
preconditioningProfile: " /path/to/profile.icc ",
basename: "trim_pre"
)
let args = try TargenArgs.build(config: config)
#expect(args[args.firstIndex(of: "-c")! + 1] == "/path/to/profile.icc")
}
@Test("Invalid basename throws") @Test("Invalid basename throws")
func invalidBasenameThrows() { func invalidBasenameThrows() {
let config = TargenConfig( let config = TargenConfig(
@@ -262,7 +290,7 @@ struct ArgyllRunnerTargenTests {
#expect(ti1URL.lastPathComponent == "mock_test.ti1") #expect(ti1URL.lastPathComponent == "mock_test.ti1")
} }
@Test("Failed targen execution throws processFailed") @Test("Failed targen execution throws toolFailed")
func failedTargenExecution() 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)
@@ -290,7 +318,8 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.self) { await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} }
} }
@@ -323,7 +352,8 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.self) { await #expect(throws: ArgyllRunnerError.missingArtefact(
tempDir.appendingPathComponent("no_file.ti1").path)) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} }
} }
@@ -0,0 +1,42 @@
import Foundation
import Testing
@testable import ICCeryCore
@testable import ICCery
/// Dataset-import error contracts through the
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
/// failures must surface identically as a single `.error` Notice.
@Suite("TargetWorkflowViewModel dataset import")
@MainActor
struct TargetWorkflowViewModelTests {
@Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'")
func malformedDatasetNotice() throws {
let env = try TestAppEnvironment.make()
defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment)
let bad = env.root.appendingPathComponent("broken.ti3")
try Data("this is not CGATS data".utf8).write(to: bad)
vm.importMeasurementDataset(from: bad)
let notice = try #require(vm.wizard.notice)
#expect(notice.kind == .error)
#expect(notice.text.hasPrefix("Import failed:"))
}
@Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'")
func missingDatasetNotice() throws {
let env = try TestAppEnvironment.make()
defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment)
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
vm.importMeasurementDataset(from: missing)
let notice = try #require(vm.wizard.notice)
#expect(notice.kind == .error)
#expect(notice.text.hasPrefix("Import failed:"))
}
}
@@ -0,0 +1,62 @@
import Testing
import Foundation
@testable import ICCeryCore
@testable import ICCery
/// Shared app-test dependency factory (issue #82).
///
/// Every store is pointed at a unique temporary directory so tests never
/// read or write the user's real Application Support tree, and a fresh
/// `ProcessManager` keeps child-process state isolated per test. The
/// global process environment is never mutated.
struct TestAppEnvironment {
/// Root temp directory holding all per-test state files.
let root: URL
let environment: AppEnvironment
var settingsURL: URL { root.appendingPathComponent("settings.json") }
var stateURL: URL { root.appendingPathComponent("wizard_state.json") }
var historyURL: URL {
root.appendingPathComponent("verification_history.json")
}
/// Creates an isolated environment under `NSTemporaryDirectory()`.
/// Call `cleanup()` when finished.
static func make() throws -> TestAppEnvironment {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: root, withIntermediateDirectories: true
)
let processManager = ProcessManager()
let settingsStore = SettingsStore(
fileURL: root.appendingPathComponent("settings.json")
)
let environment = AppEnvironment(
stateStore: WizardStateStore(
fileURL: root.appendingPathComponent("wizard_state.json")
),
settingsStore: settingsStore,
presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner(
processManager: processManager,
binaryResolver: BinaryResolver(overrideDir: nil)
),
cupsService: CupsService(
processManager: processManager,
binaryDir: root.appendingPathComponent("cups-bin")
),
historyStore: VerificationHistoryStore(
url: root.appendingPathComponent("verification_history.json")
)
)
return TestAppEnvironment(root: root, environment: environment)
}
/// Removes the temporary root directory.
func cleanup() {
try? FileManager.default.removeItem(at: root)
}
}
@@ -131,6 +131,57 @@ struct VerificationHistoryStoreTests {
} }
} }
@Test("Clear does not overwrite an unparseable file")
func clearPreservesUnparseableFile() async {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
let badJSON = "not json"
try? badJSON.write(to: url, atomically: true, encoding: .utf8)
let store = VerificationHistoryStore(url: url)
do {
try await store.clear()
Issue.record("clear() should propagate the load error")
} catch {
let contents = try? String(contentsOf: url, encoding: .utf8)
#expect(contents == badJSON)
}
}
@Test("ISO-8601 timestamps round-trip through a fresh store")
func iso8601RoundTrip() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
let timestamp = Date(timeIntervalSince1970: 1_700_000_000)
let record = VerificationRecord(
id: "vr-iso",
profileName: "p",
printerName: "",
avgDE: 1.0,
maxDE: 2.0,
rmsDE: 1.5,
patchCount: 1,
status: .good,
timestamp: timestamp
)
let store1 = VerificationHistoryStore(url: url)
_ = try await store1.append(record)
let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains(ISO8601DateFormatter().string(from: timestamp)))
let store2 = VerificationHistoryStore(url: url)
let loaded = try await store2.load()
#expect(loaded.count == 1)
#expect(loaded.first?.timestamp == timestamp)
}
@Test("CSV export quoting") @Test("CSV export quoting")
func csvQuoting() async throws { func csvQuoting() async throws {
let fm = FileManager.default let fm = FileManager.default
@@ -117,6 +117,17 @@ struct WizardStateStoreTests {
#expect(WizardStateStore(fileURL: url).load().stage == .generate) #expect(WizardStateStore(fileURL: url).load().stage == .generate)
} }
@Test func corruptJsonReturnsDefaultAndKeepsBytes() throws {
let url = tempURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "not json".write(to: url, atomically: true, encoding: .utf8)
#expect(WizardStateStore(fileURL: url).load() == .default)
let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json")
}
@Test func sessionModeCalibrationRoundTrips() throws { @Test func sessionModeCalibrationRoundTrips() throws {
var s = WizardState(sessionMode: .calibration) var s = WizardState(sessionMode: .calibration)
let data = try JSONEncoder().encode(s) let data = try JSONEncoder().encode(s)
@@ -133,6 +133,18 @@ final class Milestone2UITests: XCTestCase {
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5)) XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
} }
/// Stage 1/2 process-log containers resolve under the shared
/// `ProcessLogView` identifiers (issue #80).
func testProcessLogContainersResolve() throws {
launchApp()
XCTAssertTrue(waitFor("targenLogContainer").exists)
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
XCTAssertTrue(element("printtargLogContainer").exists)
}
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2. /// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
func testTargenFixtureUnlocksStage2() throws { func testTargenFixtureUnlocksStage2() throws {
launchApp() launchApp()
@@ -146,6 +146,8 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(notice.waitForExistence(timeout: 10)) XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "") XCTAssertTrue((notice.value as? String ?? "")
.contains("cancelled")) .contains("cancelled"))
// Cancellation is informational, never an error (#80).
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
} }
/// Preferences OK captured options are replayed verbatim in the /// Preferences OK captured options are replayed verbatim in the
@@ -214,6 +216,8 @@ final class Milestone3UITests: XCTestCase {
let notice = app.staticTexts.containing(predicate).firstMatch let notice = app.staticTexts.containing(predicate).firstMatch
XCTAssertTrue(notice.waitForExistence(timeout: 10)) XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue(notice.label.contains("Print failed")) XCTAssertTrue(notice.label.contains("Print failed"))
// Spool failure exposes the .error kind on the icon (#80).
XCTAssertEqual(element("printNotificationIcon").value as? String, "error")
} }
/// wizardState.printerName records the queue used for spooling (#95). /// wizardState.printerName records the queue used for spooling (#95).
@@ -140,4 +140,57 @@ final class Milestone4UITests: XCTestCase {
} }
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
} }
/// Two passes + a failing `average` run promote pass 1 to the
/// canonical .ti3 and show the sticky finish error notice via
/// `chartreadFinishNotice` (issue #80).
func testTwoPassAverageFailurePromotesFirstPass() throws {
app.launchEnvironment["MOCK_AVERAGE_FAIL"] = "1"
reachStage3()
app.buttons["btnDetectInstruments"].click()
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
driveOnePass(startButton: "btnStartRead")
_ = waitFor("chartreadAveragingPanel", timeout: 20)
driveOnePass(startButton: "btnMeasureAnotherSheet")
XCTAssertTrue(waitFor("btnFinishAndAverage", timeout: 20).exists)
app.buttons["btnFinishAndAverage"].click()
// Averaging failed pass 1 is promoted to the canonical .ti3
// and the sticky error notice stays on Stage 3.
let ti3 = workDir.appendingPathComponent("mytarget.ti3")
let deadline = Date().addingTimeInterval(20)
while Date() < deadline, !FileManager.default.fileExists(atPath: ti3.path) {
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
let notice = element("chartreadFinishNotice")
XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertEqual(notice.value as? String, "error")
}
/// Runs the mock handheld chartread session to completion
/// (start calibrate strip A strip B Done & Save).
private func driveOnePass(startButton: String) {
let start = app.buttons[startButton]
XCTAssertTrue(start.waitForExistence(timeout: 10))
let deadline = Date().addingTimeInterval(10)
while Date() < deadline, !start.isEnabled {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(start.isEnabled)
start.click()
_ = waitFor("btnCalibrate", timeout: 25)
app.buttons["btnCalibrate"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
_ = waitFor("btnDoneRead", timeout: 20)
app.buttons["btnDoneRead"].firstMatch.click()
}
} }
@@ -111,4 +111,21 @@ final class Milestone5UITests: XCTestCase {
"Expected verification status, got '\(statusValue)'" "Expected verification status, got '\(statusValue)'"
) )
} }
/// A failing colprof run surfaces through the session-wide wizard
/// notice only no duplicate stage-local error view (issue #80).
func testProfileFailureShowsWizardNotice() throws {
app.launchEnvironment["ICCERY_MOCK_COLPROF_EXIT"] = "2"
launchApp()
let create = waitFor("btnCreateProfile")
XCTAssertTrue(create.isEnabled)
create.click()
let notice = element("noticeText")
XCTAssertTrue(notice.waitForExistence(timeout: 20))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Profile creation failed"))
XCTAssertFalse(element("colprofLastError").exists)
}
} }
@@ -32,6 +32,7 @@ final class Milestone6CalibrationUITests: XCTestCase {
"ICCERY_TEST_WORKDIR": testWorkDir.path "ICCERY_TEST_WORKDIR": testWorkDir.path
] ]
app.launch() app.launch()
app.activate()
} }
override func tearDown() async throws { override func tearDown() async throws {
@@ -77,4 +78,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
let layout = app.buttons["btnCreateLayout"] let layout = app.buttons["btnCreateLayout"]
XCTAssertTrue(layout.waitForExistence(timeout: 25)) XCTAssertTrue(layout.waitForExistence(timeout: 25))
} }
/// A failing calibration targen surfaces the error through the
/// wizard notice and restores the original basename (issue #80).
func testCalibrationTargenFailureRestoresBasename() throws {
let testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("cal-fail-\(UUID().uuidString)")
let appData = testRoot.appendingPathComponent("AppData")
try FileManager.default.createDirectory(
at: appData, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: testRoot) }
// Pre-stage wizard state so the failing mock targen is only
// exercised by the calibration run, not target generation.
let state: [String: Any] = [
"currentStage": 1,
"basename": "DemoTarget",
"cwd": testWorkDir.path,
"sessionMode": "profile",
"calibrationOriginalBasename": ""
]
let stateURL = appData.appendingPathComponent("wizard_state.json")
try JSONSerialization.data(withJSONObject: state).write(to: stateURL)
app.terminate()
app.launchEnvironment["ICCERY_TEST_ROOT"] = testRoot.path
app.launchEnvironment["ICCERY_MOCK_TARGEN_EXIT"] = "2"
app.launch()
app.activate()
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
let calGenerate = app.buttons["btnCalGenerate"]
XCTAssertTrue(calGenerate.waitForExistence(timeout: 10))
calGenerate.tap()
let notice = app.descendants(matching: .any)["noticeText"]
XCTAssertTrue(notice.waitForExistence(timeout: 20))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Calibration target failed"))
// The pre-CAL_ basename is restored and persisted.
let deadline = Date().addingTimeInterval(10)
var restoredBasename: String?
while Date() < deadline {
if let data = try? Data(contentsOf: stateURL),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let basename = object["basename"] as? String {
restoredBasename = basename
if basename == "DemoTarget" { break }
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertEqual(restoredBasename, "DemoTarget")
}
} }
+12 -12
View File
@@ -2,7 +2,7 @@ name: ICCery
options: options:
bundleIdPrefix: com.gronod bundleIdPrefix: com.gronod
deploymentTarget: deploymentTarget:
macOS: "14.0" macOS: "12.0"
groupSortPosition: top groupSortPosition: top
packages: packages:
@@ -13,7 +13,7 @@ targets:
ICCery: ICCery:
type: application type: application
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Sources/ICCery - path: Sources/ICCery
- path: Resources - path: Resources
@@ -46,7 +46,7 @@ targets:
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: ICCery INFOPLIST_KEY_CFBundleDisplayName: ICCery
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0" MARKETING_VERSION: "2.0.0"
@@ -58,15 +58,15 @@ targets:
ENABLE_APP_SANDBOX: NO ENABLE_APP_SANDBOX: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
SWIFT_STRICT_CONCURRENCY: complete OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ARCHS: "$(ARCHS_STANDARD)" ARCHS: "$(ARCHS_STANDARD)"
ICCeryCoreTests: ICCeryCoreTests:
type: bundle.unit-test type: bundle.unit-test
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Tests/ICCeryCoreTests - path: Tests/ICCeryCoreTests
dependencies: dependencies:
@@ -79,13 +79,13 @@ targets:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery" TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ICCeryUITests: ICCeryUITests:
type: bundle.ui-testing type: bundle.ui-testing
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Tests/ICCeryUITests - path: Tests/ICCeryUITests
dependencies: dependencies:
@@ -95,8 +95,8 @@ targets:
TEST_TARGET_NAME: ICCery TEST_TARGET_NAME: ICCery
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
schemes: schemes:
ICCery: ICCery: