diff --git a/Sources/ICCery/CalibrationView.swift b/Sources/ICCery/CalibrationView.swift index a2d25dc..b4eb94c 100644 --- a/Sources/ICCery/CalibrationView.swift +++ b/Sources/ICCery/CalibrationView.swift @@ -95,13 +95,6 @@ struct CalibrationView: View { .frame(minHeight: 80, maxHeight: 120) } } - - if let error = model.lastError { - Section { - Text(error) - .foregroundStyle(.red) - } - } } .formStyle(.grouped) diff --git a/Sources/ICCery/CalibrationViewModel.swift b/Sources/ICCery/CalibrationViewModel.swift index a327019..d19db7f 100644 --- a/Sources/ICCery/CalibrationViewModel.swift +++ b/Sources/ICCery/CalibrationViewModel.swift @@ -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 diff --git a/Sources/ICCery/MeasurementWorkflowViewModel.swift b/Sources/ICCery/MeasurementWorkflowViewModel.swift index 688ae3a..6ec4bba 100644 --- a/Sources/ICCery/MeasurementWorkflowViewModel.swift +++ b/Sources/ICCery/MeasurementWorkflowViewModel.swift @@ -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? // 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 } } } diff --git a/Sources/ICCery/NoticeBanner.swift b/Sources/ICCery/NoticeBanner.swift index 554c222..a5c7454 100644 --- a/Sources/ICCery/NoticeBanner.swift +++ b/Sources/ICCery/NoticeBanner.swift @@ -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() diff --git a/Sources/ICCery/ProfileWorkflowViewModel.swift b/Sources/ICCery/ProfileWorkflowViewModel.swift index f0f419a..8426e61 100644 --- a/Sources/ICCery/ProfileWorkflowViewModel.swift +++ b/Sources/ICCery/ProfileWorkflowViewModel.swift @@ -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 { diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index d201198..9d2c784 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -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 diff --git a/Sources/ICCery/Stage3View.swift b/Sources/ICCery/Stage3View.swift index 1c9aa2c..4ee7a65 100644 --- a/Sources/ICCery/Stage3View.swift +++ b/Sources/ICCery/Stage3View.swift @@ -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) diff --git a/Sources/ICCery/Stage4View.swift b/Sources/ICCery/Stage4View.swift index e879abb..21333a6 100644 --- a/Sources/ICCery/Stage4View.swift +++ b/Sources/ICCery/Stage4View.swift @@ -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) diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 67f4ab8..3afa2e7 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -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 diff --git a/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift new file mode 100644 index 0000000..79ebe1d --- /dev/null +++ b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift @@ -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) + } +} diff --git a/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift b/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift new file mode 100644 index 0000000..beac3f3 --- /dev/null +++ b/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift @@ -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:")) + } +} diff --git a/Tests/ICCeryUITests/Milestone2UITests.swift b/Tests/ICCeryUITests/Milestone2UITests.swift index a648e7e..31bdded 100644 --- a/Tests/ICCeryUITests/Milestone2UITests.swift +++ b/Tests/ICCeryUITests/Milestone2UITests.swift @@ -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() diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index 94a4218..ad4a294 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -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). diff --git a/Tests/ICCeryUITests/Milestone4UITests.swift b/Tests/ICCeryUITests/Milestone4UITests.swift index 92759aa..66fda1e 100644 --- a/Tests/ICCeryUITests/Milestone4UITests.swift +++ b/Tests/ICCeryUITests/Milestone4UITests.swift @@ -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() + } } diff --git a/Tests/ICCeryUITests/Milestone5UITests.swift b/Tests/ICCeryUITests/Milestone5UITests.swift index 3c6929b..91150d7 100644 --- a/Tests/ICCeryUITests/Milestone5UITests.swift +++ b/Tests/ICCeryUITests/Milestone5UITests.swift @@ -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) + } } diff --git a/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift index 272219b..cb8aced 100644 --- a/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift +++ b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift @@ -77,4 +77,59 @@ 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() + + 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") + } }