Author SHA1 Message Date
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
28 changed files with 832 additions and 190 deletions
+1
View File
@@ -27,4 +27,5 @@ ICCery.xcodeproj/
Release/
notarization/
build/
docs/megaplans/*
docs/megaplans
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
}
args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster:
args.append("-r")
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
}
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi)
}
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !CalibrationIdentity.isCalibration(cleanBasename),
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
!cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
if !CalibrationIdentity.isCalibration(cleanBasename) {
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
}
args.append(cleanBasename)
@@ -70,18 +70,12 @@ public enum TargenArgs {
if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"])
}
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
}
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
args.append(contentsOf: ["-c", c])
}
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)])
}
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
}))
if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag)
}
@@ -91,11 +85,9 @@ public enum TargenArgs {
}
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
}
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(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
if let p = config.devicePower, p > 0 {
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
}
args.append(cleanBasename)
@@ -79,16 +79,9 @@ public enum PrintcalArgs {
var args: [String] = ["-v", "-e"]
if config.noInkLimit {
args.append("-I")
}
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit {
-7
View File
@@ -95,13 +95,6 @@ struct CalibrationView: View {
.frame(minHeight: 80, maxHeight: 120)
}
}
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.formStyle(.grouped)
+20 -23
View File
@@ -25,7 +25,6 @@ final class CalibrationViewModel {
var calibrationLog: [String] = []
var isGenerating = false
var isComputing = false
var lastError: String?
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
@@ -77,10 +76,6 @@ final class CalibrationViewModel {
wizard.basename = identity.calibrationBasename
wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig(
colourSpace: colourSpace,
steps: steps,
@@ -92,17 +87,19 @@ final class CalibrationViewModel {
)
Task { @MainActor in
defer { self.isGenerating = false }
do {
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.calibrationLog.append(contentsOf: batch)
})
_ = try await ProcessRunSupport.runLogged(
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.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)",
kind: .error
@@ -137,15 +134,13 @@ final class CalibrationViewModel {
// "already exists" when the user declines overwrite. We do not
// silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) {
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
wizard.showNotice(lastError!, kind: .error)
wizard.showNotice(
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
kind: .error
)
return
}
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig(
ti3Basename: calBasename,
workingDirectory: cwd,
@@ -158,19 +153,21 @@ final class CalibrationViewModel {
)
Task { @MainActor in
defer { self.isComputing = false }
do {
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.calibrationLog.append(contentsOf: batch)
})
let url = try await ProcessRunSupport.runLogged(
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.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration()
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)",
kind: .error
@@ -63,7 +63,8 @@ final class MeasurementWorkflowViewModel {
var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false
var lastError: String?
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging
@@ -185,7 +186,7 @@ final class MeasurementWorkflowViewModel {
isChartreadRunning = true
chartreadState = .idle
currentPrompt = nil
lastError = nil
chartreadNotice = nil
chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass.
@@ -227,7 +228,10 @@ final class MeasurementWorkflowViewModel {
case .exit(let code):
if code != 0 {
lastError = "chartread exited with code \(code)"
chartreadNotice = Notice(
kind: .error,
text: "chartread exited with code \(code)"
)
}
case .completed(let canonicalURL):
@@ -235,7 +239,7 @@ final class MeasurementWorkflowViewModel {
completePass(canonicalURL: canonicalURL)
case .failed(let error):
lastError = error.localizedDescription
chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
chartreadState = .error
isChartreadRunning = false
}
@@ -381,7 +385,10 @@ final class MeasurementWorkflowViewModel {
discoverPassSnapshots()
wizard.refreshGating()
} 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() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil
Task { @MainActor [weak self] in
guard let self else { return }
do {
let canonical: URL
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
canonical = try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
} else {
// 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 {
return try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
}
let config = AverageConfig(
workingDirectory: cwd,
basename: self.basename,
passFiles: self.passSnapshots
)
canonical = try await self.environment.runner.runAverage(
return try await self.environment.runner.runAverage(
config: config,
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.chartreadLog.append(contentsOf: batch)
}
onLogBatch: onLog
)
}
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
}
}
var accessibilityValue: String {
switch self {
case .info: return "info"
case .warning: return "warning"
case .error: return "error"
}
}
}
let id = UUID()
+53 -50
View File
@@ -31,7 +31,6 @@ final class ProfileWorkflowViewModel {
var isColprofRunning = false
var colprofLog: [String] = []
var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL?
@@ -165,60 +164,59 @@ final class ProfileWorkflowViewModel {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil
lastError = nil
createdProfileURL = nil
createdGamutURL = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isColprofRunning = false }
do {
let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
guard let self else { return }
self.colprofLog.append(contentsOf: batch)
if let last = batch.last {
self.updateProgress(ColprofProgressClassifier.classify(line: last))
let outcome = try await ProcessRunSupport.runLogged(
setRunning: { self.isColprofRunning = $0 },
resetLog: { self.colprofLog = [] },
onLog: { batch in
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 {
let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig)
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)
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
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.createdProfileURL = outcome.profileURL
self.createdGamutURL = outcome.gamutURL
self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)",
kind: .error
@@ -285,23 +283,28 @@ final class ProfileWorkflowViewModel {
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil
profcheckWarning = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isProfcheckRunning = false }
do {
let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.colprofLog.append(contentsOf: batch)
})
self.profcheckReport = report
if let record = self.makeVerificationRecord(from: report) {
let updated = try await self.environment.historyStore.append(record)
self.verificationHistory = updated
let outcome = try await ProcessRunSupport.runLogged(
setRunning: { self.isProfcheckRunning = $0 },
resetLog: {},
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) {
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)
}
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
+1
View File
@@ -218,6 +218,7 @@ struct Stage2View: View {
.foregroundStyle(notice.kind == .error
? .red : .blue)
.accessibilityIdentifier("printNotificationIcon")
.accessibilityValue(notice.kind.accessibilityValue)
Text(notice.text)
.font(.caption)
.foregroundStyle(notice.kind == .error
+11 -15
View File
@@ -164,28 +164,22 @@ struct Stage3View: View {
.foregroundStyle(Theme.accent)
}
if let lastError = model.lastError {
Text(lastError)
if let notice = model.chartreadNotice {
Text(notice.text)
.font(.caption)
.foregroundStyle(.red)
.foregroundStyle(notice.kind.tint)
.accessibilityIdentifier("chartreadLastError")
.accessibilityValue(lastError)
.accessibilityValue(notice.text)
}
controlButtons
if !model.chartreadLog.isEmpty {
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.chartreadLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
ProcessLogView(
lines: model.chartreadLog,
containerId: "chartreadLogContainer",
logId: "chartreadLog"
)
}
}
.padding(16)
@@ -374,6 +368,8 @@ struct Stage3View: View {
Text(notice.text)
.font(.caption)
.foregroundStyle(notice.kind == .error ? .red : .green)
.accessibilityIdentifier("chartreadFinishNotice")
.accessibilityValue(notice.kind.accessibilityValue)
}
}
.padding(16)
+5 -18
View File
@@ -166,27 +166,14 @@ struct Stage4View: View {
}
Spacer()
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("colprofLastError")
}
}
if !model.colprofLog.isEmpty {
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.colprofLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
ProcessLogView(
lines: model.colprofLog,
containerId: "colprofLogContainer",
logId: "colprofLog"
)
}
}
.padding(16)
+5 -5
View File
@@ -206,8 +206,6 @@ final class TargetWorkflowViewModel {
func generateTarget() {
guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false
let runner = environment.runner
Task { @MainActor in
@@ -228,7 +226,6 @@ final class TargetWorkflowViewModel {
} catch {
wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error)
targenRunning = false
}
}
}
@@ -242,7 +239,12 @@ final class TargetWorkflowViewModel {
? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile()
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 {
let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
@@ -339,8 +341,6 @@ final class TargetWorkflowViewModel {
func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil
let runner = environment.runner
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")
struct ArgyllRunnerCalibrationTests {
private func makeRunner() -> ArgyllRunner {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner(
processManager: .shared,
processManager: processManager,
binaryResolver: BinaryResolver(overrideDir: binDir)
)
}
@@ -44,8 +44,9 @@ struct ArgyllRunnerCalibrationTests {
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
func calibrationTargenProcessId() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let events = ProcessManager.shared.events()
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 {
@@ -87,10 +88,28 @@ struct ArgyllRunnerCalibrationTests {
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal failure throws printcalFailed")
@Test("printcal failure throws toolFailed")
func printcalFailureThrows() async throws {
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 config = PrintcalConfig(
ti3Basename: "CAL_demo",
@@ -98,12 +117,9 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output
)
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
await #expect(throws: (any Error).self) {
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
_ = 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)
let runner = ArgyllRunner(
processManager: .shared,
processManager: ProcessManager(),
binaryResolver: BinaryResolver(overrideDir: binDir)
)
@@ -49,4 +49,32 @@ struct ArgyllRunnerColprofTests {
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")
}
}
@@ -159,6 +159,51 @@ struct ChartreadRowTests {
#expect(row.patchCount == 1)
#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")
@@ -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")
func rejectsBadChannelLimit() {
let config = PrintcalConfig(
+20 -2
View File
@@ -131,6 +131,23 @@ struct PrinttargArgsTests {
#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")
func unsafeBasename() {
#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 {
let dir = try makeFixture("""
#!/bin/sh
@@ -360,7 +377,8 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner(
processManager: ProcessManager(),
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(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
}
@@ -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"))
}
@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")
func invalidBasenameThrows() {
let config = TargenConfig(
@@ -262,7 +290,7 @@ struct ArgyllRunnerTargenTests {
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
}
@Test("Failed targen execution throws processFailed")
@Test("Failed targen execution throws toolFailed")
func failedTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -290,7 +318,8 @@ struct ArgyllRunnerTargenTests {
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)
}
}
@@ -323,7 +352,8 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir
)
await #expect(throws: ArgyllRunnerError.self) {
await #expect(throws: ArgyllRunnerError.missingArtefact(
tempDir.appendingPathComponent("no_file.ti1").path)) {
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))
}
/// 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.
func testTargenFixtureUnlocksStage2() throws {
launchApp()
@@ -146,6 +146,8 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "")
.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
@@ -214,6 +216,8 @@ final class Milestone3UITests: XCTestCase {
let notice = app.staticTexts.containing(predicate).firstMatch
XCTAssertTrue(notice.waitForExistence(timeout: 10))
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).
@@ -140,4 +140,57 @@ final class Milestone4UITests: XCTestCase {
}
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)'"
)
}
/// 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
]
app.launch()
app.activate()
}
override func tearDown() async throws {
@@ -77,4 +78,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
let layout = app.buttons["btnCreateLayout"]
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")
}
}