Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
891a504ee7 | ||
|
|
f681e60778 | ||
|
|
0d0233e8d6 | ||
|
|
d117d7a510 | ||
|
|
78ffeff61b | ||
|
|
0298f69a2c |
@@ -95,13 +95,6 @@ struct CalibrationView: View {
|
||||
.frame(minHeight: 80, maxHeight: 120)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = model.lastError {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -366,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
|
||||
@@ -377,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)
|
||||
}
|
||||
}
|
||||
@@ -290,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)
|
||||
@@ -318,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)
|
||||
}
|
||||
}
|
||||
@@ -351,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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user