Compare commits

...
Author SHA1 Message Date
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
34 changed files with 1232 additions and 246 deletions
@@ -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)
@@ -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
} }
@@ -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
@@ -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 {
-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.
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first { let canonical = try await ProcessRunSupport.runLogged(
canonical = try MeasurementArtefacts.promotePass( setRunning: { self.isFinishing = $0 },
pass: pass, resetLog: {},
basename: self.basename, onLog: { self.chartreadLog.append(contentsOf: $0) }
cwd: cwd ) { onLog in
) if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
} else { return try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
}
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()
+53 -50
View File
@@ -31,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?
@@ -165,60 +164,59 @@ 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 },
self.colprofLog.append(contentsOf: batch) resetLog: { self.colprofLog = [] },
if let last = batch.last { onLog: { batch in
self.updateProgress(ColprofProgressClassifier.classify(line: last)) self.colprofLog.append(contentsOf: batch)
if let last = batch.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
if self.applyCalibration, !self.calibrationFile.isEmpty { if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig( let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile, calibrationPath: self.calibrationFile,
inputProfileURL: url inputProfileURL: url
) )
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0") assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig) finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)") self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
var gamutURL: URL?
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
gamutURL = url
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
return (profileURL: finalProfileURL, gamutURL: gamutURL)
} }
self.createdProfileURL = outcome.profileURL
// Gamut extraction is best-effort for Stage 5 / M6 viewer. self.createdGamutURL = outcome.gamutURL
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.colprofLog.append(contentsOf: batch)
})
self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
self.createdProfileURL = finalProfileURL
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
@@ -285,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) }
if let record = self.makeVerificationRecord(from: report) { ) { onLog in
let updated = try await self.environment.historyStore.append(record) let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
self.verificationHistory = updated var history: [VerificationRecord]?
if let record = self.makeVerificationRecord(from: report) {
history = try await self.environment.historyStore.append(record)
}
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
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)
+5 -5
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
@@ -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")
}
}
@@ -82,41 +82,3 @@ struct JSONFileStoreTests {
} }
} }
} }
@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")
@@ -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)
}
}
+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:"))
}
}
@@ -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)
}
} }
@@ -77,4 +77,59 @@ 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()
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")
}
} }