Compare commits

..
Author SHA1 Message Date
gronod fa9a2042bf chore: simplify megaplans gitignore to directory-level pattern
Remove redundant `docs/megaplans/*` pattern, keeping only `docs/megaplans` to ignore the entire directory.
2026-09-11 12:01:08 +01:00
36 changed files with 214 additions and 864 deletions
+2 -10
View File
@@ -14,21 +14,13 @@ jobs:
build-and-test: build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not, # Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline. # macos-14 works for this pipeline.
runs-on: macos-12 runs-on: macos-14
env: env:
DERIVED: build/DerivedData-test DERIVED: build/DerivedData-test
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Assert Xcode 14 toolchain
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
- name: Ensure host tools
run: |
command -v xcodegen || brew install xcodegen
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
- name: Generate Xcode project - name: Generate Xcode project
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
@@ -128,7 +120,7 @@ jobs:
package: package:
needs: build-and-test needs: build-and-test
runs-on: macos-12 runs-on: macos-14
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Checkout - name: Checkout
-1
View File
@@ -27,5 +27,4 @@ ICCery.xcodeproj/
Release/ Release/
notarization/ notarization/
build/ build/
docs/megaplans/*
docs/megaplans docs/megaplans
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 5.7 // swift-tools-version: 6.0
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "ICCeryCore", name: "ICCeryCore",
platforms: [.macOS(.v12)], platforms: [.macOS(.v14)],
products: [ products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]), .library(name: "ICCeryCore", targets: ["ICCeryCore"]),
], ],
@@ -200,7 +200,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id) await processManager.kill(id: id)
var attempts = 0 var attempts = 0
while await processManager.isRunning(id), attempts < 30 { while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(nanoseconds: 100_000_000) try? await Task.sleep(for: .milliseconds(100))
attempts += 1 attempts += 1
} }
} }
@@ -243,7 +243,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines { if flushPartialLines {
dotFlushTask = Task { [processManager] in dotFlushTask = Task { [processManager] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { break } if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId) await processManager.flushPartialLine(id: processId)
} }
@@ -592,7 +592,7 @@ public struct ArgyllRunner: Sendable {
await processManager.setPreKillHook(id: processId) { [processManager] in await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY { if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(for: .milliseconds(500))
} }
} }
@@ -56,19 +56,23 @@ public enum PrinttargArgs {
} }
args.append(contentsOf: ["-R", "\(config.customSeed)"]) args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster: case .raster:
args.append(contentsOf: ArgsBuilder.flag("-r", when: true)) args.append("-r")
} }
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label)) if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
guard (72...600).contains(config.dpi) else { guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi) throw PrinttargArgError.invalidDPI(config.dpi)
} }
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"]) args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !CalibrationIdentity.isCalibration(cleanBasename) { if !CalibrationIdentity.isCalibration(cleanBasename),
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty( let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile)) !cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
} }
args.append(cleanBasename) args.append(cleanBasename)
@@ -70,12 +70,18 @@ public enum TargenArgs {
if let n = config.neutralSteps, n > 0 { if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"]) args.append(contentsOf: ["-n", "\(n)"])
} }
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50)) if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile)) args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true)) }
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map { if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0) 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)])
}
if let algFlag = config.fullSpreadAlgorithm?.flag { if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag) args.append(algFlag)
} }
@@ -85,9 +91,11 @@ public enum TargenArgs {
} }
args.append(contentsOf: ["-l", "\(inkLimit)"]) args.append(contentsOf: ["-l", "\(inkLimit)"])
} }
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0)) if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
if let p = config.devicePower, p > 0 { args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0)) }
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
} }
args.append(cleanBasename) args.append(cleanBasename)
@@ -72,7 +72,7 @@ public enum CGATSParser {
public static func parse( public static func parse(
_ contents: String, _ contents: String,
sourceURL: URL? = nil sourceURL: URL? = nil
) throws -> CGATSDataset { ) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile } guard !contents.isEmpty else { throw .emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? "" let ext = sourceURL?.pathExtension.lowercased() ?? ""
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess( private static func preprocess(
_ contents: String, _ contents: String,
isCSV: Bool isCSV: Bool
) throws -> (CGATSFormat, [String]) { ) throws(CGATSParseError) -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines) let allLines = contents.components(separatedBy: .newlines)
var lines = [String]() var lines = [String]()
@@ -559,7 +559,7 @@ public actor ProcessManager {
// Start a watchdog in case the `readabilityHandler` EOFs never // Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe). // arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in child.finalizeTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(for: .seconds(2))
guard let self else { return } guard let self else { return }
await self.forceKill(id: id) await self.forceKill(id: id)
await self.forceFinalize(id: id) await self.forceFinalize(id: id)
@@ -79,9 +79,16 @@ public enum PrintcalArgs {
var args: [String] = ["-v", "-e"] var args: [String] = ["-v", "-e"]
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit)) if config.noInkLimit {
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify)) args.append("-I")
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath)) }
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
if let tac = config.totalInkLimit, tac > 0 { if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)]) args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit { } else if let tac = config.totalInkLimit {
+7
View File
@@ -95,6 +95,13 @@ struct CalibrationView: View {
.frame(minHeight: 80, maxHeight: 120) .frame(minHeight: 80, maxHeight: 120)
} }
} }
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
}
}
} }
.formStyle(.grouped) .formStyle(.grouped)
+23 -20
View File
@@ -25,6 +25,7 @@ final class CalibrationViewModel {
var calibrationLog: [String] = [] var calibrationLog: [String] = []
var isGenerating = false var isGenerating = false
var isComputing = false var isComputing = false
var lastError: String?
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow self.workflow = workflow
@@ -76,6 +77,10 @@ final class CalibrationViewModel {
wizard.basename = identity.calibrationBasename wizard.basename = identity.calibrationBasename
wizard.sessionMode = .calibration wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: colourSpace, colourSpace: colourSpace,
steps: steps, steps: steps,
@@ -87,19 +92,17 @@ final class CalibrationViewModel {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isGenerating = false }
do { do {
_ = try await ProcessRunSupport.runLogged( _ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
setRunning: { self.isGenerating = $0 }, self?.calibrationLog.append(contentsOf: batch)
resetLog: { self.calibrationLog = [] }, })
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runCalibrationTargen(
config: config, onLogBatch: onLog)
}
self.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.") self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint) self.wizard.go(to: .layOutPrint)
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)", "Calibration target failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -134,13 +137,15 @@ final class CalibrationViewModel {
// "already exists" when the user declines overwrite. We do not // "already exists" when the user declines overwrite. We do not
// silently clobber. // silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) { if FileManager.default.fileExists(atPath: outputURL.path) {
wizard.showNotice( lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.", wizard.showNotice(lastError!, kind: .error)
kind: .error
)
return return
} }
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: calBasename, ti3Basename: calBasename,
workingDirectory: cwd, workingDirectory: cwd,
@@ -153,21 +158,19 @@ final class CalibrationViewModel {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isComputing = false }
do { do {
let url = try await ProcessRunSupport.runLogged( let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
setRunning: { self.isComputing = $0 }, self?.calibrationLog.append(contentsOf: batch)
resetLog: { self.calibrationLog = [] }, })
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runPrintcal(
config: config, onLogBatch: onLog)
}
self.computedCalURL = url self.computedCalURL = url
self.profile.calibrationFile = url.path self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.") self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration() self.wizard.restoreCalibration()
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)", "Calibration curve computation failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -63,8 +63,7 @@ final class MeasurementWorkflowViewModel {
var rows: [ChartreadRow] = [] var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = [] var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false var showRemoveSheetNotice = false
/// Stage-local chartread error notice (`#chartreadLastError`, #80). var lastError: String?
var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>? private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging // MARK: - Averaging
@@ -186,7 +185,7 @@ final class MeasurementWorkflowViewModel {
isChartreadRunning = true isChartreadRunning = true
chartreadState = .idle chartreadState = .idle
currentPrompt = nil currentPrompt = nil
chartreadNotice = nil lastError = nil
chartreadLog.removeAll() chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass. // Optional: reset rows when starting a fresh first pass.
@@ -228,10 +227,7 @@ final class MeasurementWorkflowViewModel {
case .exit(let code): case .exit(let code):
if code != 0 { if code != 0 {
chartreadNotice = Notice( lastError = "chartread exited with code \(code)"
kind: .error,
text: "chartread exited with code \(code)"
)
} }
case .completed(let canonicalURL): case .completed(let canonicalURL):
@@ -239,7 +235,7 @@ final class MeasurementWorkflowViewModel {
completePass(canonicalURL: canonicalURL) completePass(canonicalURL: canonicalURL)
case .failed(let error): case .failed(let error):
chartreadNotice = Notice(kind: .error, text: error.localizedDescription) lastError = error.localizedDescription
chartreadState = .error chartreadState = .error
isChartreadRunning = false isChartreadRunning = false
} }
@@ -385,10 +381,7 @@ final class MeasurementWorkflowViewModel {
discoverPassSnapshots() discoverPassSnapshots()
wizard.refreshGating() wizard.refreshGating()
} catch { } catch {
chartreadNotice = Notice( lastError = "Could not snapshot pass: \(error.localizedDescription)"
kind: .error,
text: "Could not snapshot pass: \(error.localizedDescription)"
)
} }
} }
@@ -404,32 +397,30 @@ final class MeasurementWorkflowViewModel {
func finishAndAverage() { func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return } guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil finishNotice = nil
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
do { do {
// No log reset: prior chartread output must be preserved. let canonical: URL
let canonical = try await ProcessRunSupport.runLogged( if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
setRunning: { self.isFinishing = $0 }, canonical = try MeasurementArtefacts.promotePass(
resetLog: {}, pass: pass,
onLog: { self.chartreadLog.append(contentsOf: $0) } basename: self.basename,
) { onLog in cwd: cwd
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first { )
return try MeasurementArtefacts.promotePass( } else {
pass: pass,
basename: self.basename,
cwd: cwd
)
}
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
basename: self.basename, basename: self.basename,
passFiles: self.passSnapshots passFiles: self.passSnapshots
) )
return try await self.environment.runner.runAverage( canonical = try await self.environment.runner.runAverage(
config: config, config: config,
onLogBatch: onLog onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.chartreadLog.append(contentsOf: batch)
}
) )
} }
self.discoverPassSnapshots() self.discoverPassSnapshots()
@@ -474,6 +465,7 @@ final class MeasurementWorkflowViewModel {
) )
} }
} }
self.isFinishing = false
} }
} }
} }
-8
View File
@@ -21,14 +21,6 @@ struct Notice: Identifiable, Equatable {
case .error: return .red case .error: return .red
} }
} }
var accessibilityValue: String {
switch self {
case .info: return "info"
case .warning: return "warning"
case .error: return "error"
}
}
} }
let id = UUID() let id = UUID()
+50 -53
View File
@@ -31,6 +31,7 @@ final class ProfileWorkflowViewModel {
var isColprofRunning = false var isColprofRunning = false
var colprofLog: [String] = [] var colprofLog: [String] = []
var colprofProgress: String? var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL? var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL? var createdGamutURL: URL?
@@ -164,59 +165,60 @@ final class ProfileWorkflowViewModel {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return } guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig() let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil colprofProgress = nil
lastError = nil
createdProfileURL = nil createdProfileURL = nil
createdGamutURL = nil createdGamutURL = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.isColprofRunning = false }
do { do {
let outcome = try await ProcessRunSupport.runLogged( let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
setRunning: { self.isColprofRunning = $0 }, guard let self else { return }
resetLog: { self.colprofLog = [] }, self.colprofLog.append(contentsOf: batch)
onLog: { batch in if let last = batch.last {
self.colprofLog.append(contentsOf: batch) self.updateProgress(ColprofProgressClassifier.classify(line: last))
if let last = batch.last {
self.updateProgress(ColprofProgressClassifier.classify(line: last))
}
} }
) { onLog in })
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
var finalProfileURL = url var finalProfileURL = url
if self.applyCalibration, !self.calibrationFile.isEmpty { if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig( let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile, calibrationPath: self.calibrationFile,
inputProfileURL: url inputProfileURL: url
) )
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0") assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig) finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)") self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
var gamutURL: URL?
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
gamutURL = url
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
return (profileURL: finalProfileURL, gamutURL: gamutURL)
} }
self.createdProfileURL = outcome.profileURL
self.createdGamutURL = outcome.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.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)") self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall) self.wizard.go(to: .verifyInstall)
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)", "Profile creation failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -283,28 +285,23 @@ final class ProfileWorkflowViewModel {
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd) let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL) let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil profcheckReport = nil
profcheckWarning = nil profcheckWarning = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.isProfcheckRunning = false }
do { do {
let outcome = try await ProcessRunSupport.runLogged( let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
setRunning: { self.isProfcheckRunning = $0 }, self?.colprofLog.append(contentsOf: batch)
resetLog: {}, })
onLog: { self.colprofLog.append(contentsOf: $0) } self.profcheckReport = report
) { onLog in if let record = self.makeVerificationRecord(from: report) {
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog) let updated = try await self.environment.historyStore.append(record)
var history: [VerificationRecord]? self.verificationHistory = updated
if let record = self.makeVerificationRecord(from: report) {
history = try await self.environment.historyStore.append(record)
}
return (report: report, history: history)
}
self.profcheckReport = outcome.report
if let history = outcome.history {
self.verificationHistory = history
self.driftAlert = DriftAlert.compute(from: self.filteredHistory) self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
} }
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable { } catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
+1 -1
View File
@@ -33,7 +33,7 @@ final class SettingsViewModel {
sink.applySettings(settings) sink.applySettings(settings)
savedFlash = true savedFlash = true
Task { Task {
try? await Task.sleep(nanoseconds: 1_500_000_000) try? await Task.sleep(for: .seconds(1.5))
savedFlash = false savedFlash = false
} }
return true return true
-1
View File
@@ -218,7 +218,6 @@ struct Stage2View: View {
.foregroundStyle(notice.kind == .error .foregroundStyle(notice.kind == .error
? .red : .blue) ? .red : .blue)
.accessibilityIdentifier("printNotificationIcon") .accessibilityIdentifier("printNotificationIcon")
.accessibilityValue(notice.kind.accessibilityValue)
Text(notice.text) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(notice.kind == .error .foregroundStyle(notice.kind == .error
+15 -11
View File
@@ -164,22 +164,28 @@ struct Stage3View: View {
.foregroundStyle(Theme.accent) .foregroundStyle(Theme.accent)
} }
if let notice = model.chartreadNotice { if let lastError = model.lastError {
Text(notice.text) Text(lastError)
.font(.caption) .font(.caption)
.foregroundStyle(notice.kind.tint) .foregroundStyle(.red)
.accessibilityIdentifier("chartreadLastError") .accessibilityIdentifier("chartreadLastError")
.accessibilityValue(notice.text) .accessibilityValue(lastError)
} }
controlButtons controlButtons
if !model.chartreadLog.isEmpty { if !model.chartreadLog.isEmpty {
ProcessLogView( DisclosureGroup("Log") {
lines: model.chartreadLog, VStack(alignment: .leading) {
containerId: "chartreadLogContainer", ForEach(model.chartreadLog, id: \.self) { line in
logId: "chartreadLog" Text(line)
) .font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
} }
} }
.padding(16) .padding(16)
@@ -368,8 +374,6 @@ struct Stage3View: View {
Text(notice.text) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(notice.kind == .error ? .red : .green) .foregroundStyle(notice.kind == .error ? .red : .green)
.accessibilityIdentifier("chartreadFinishNotice")
.accessibilityValue(notice.kind.accessibilityValue)
} }
} }
.padding(16) .padding(16)
+18 -5
View File
@@ -166,14 +166,27 @@ struct Stage4View: View {
} }
Spacer() Spacer()
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("colprofLastError")
}
} }
if !model.colprofLog.isEmpty { if !model.colprofLog.isEmpty {
ProcessLogView( DisclosureGroup("Log") {
lines: model.colprofLog, VStack(alignment: .leading) {
containerId: "colprofLogContainer", ForEach(model.colprofLog, id: \.self) { line in
logId: "colprofLog" Text(line)
) .font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
} }
} }
.padding(16) .padding(16)
+5 -5
View File
@@ -206,6 +206,8 @@ final class TargetWorkflowViewModel {
func generateTarget() { func generateTarget() {
guard canGenerate, !targenRunning else { return } guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig() let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false resumedFromTi2 = false
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
@@ -226,6 +228,7 @@ final class TargetWorkflowViewModel {
} catch { } catch {
wizard.showNotice( wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error) "targen failed: \(error.localizedDescription)", kind: .error)
targenRunning = false
} }
} }
} }
@@ -239,12 +242,7 @@ final class TargetWorkflowViewModel {
? UITestHooks.datasetImportURL ? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile() : fileDialogs.selectDatasetFile()
guard let url else { return } guard let url else { return }
importMeasurementDataset(from: url)
}
/// Test seam (issue #80): unit tests pass missing or malformed URLs
/// directly instead of mutating the global environment.
func importMeasurementDataset(from url: URL) {
do { do {
let dataset = try CGATSParser.parse(url: url) let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else { guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
@@ -341,6 +339,8 @@ final class TargetWorkflowViewModel {
func createLayout() { func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return } guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig() let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil printtargResult = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
+1 -1
View File
@@ -201,7 +201,7 @@ final class WizardViewModel {
self.notice = notice self.notice = notice
if let delay = notice.autoHideAfter { if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in noticeDismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id { if self?.notice?.id == notice.id {
self?.notice = nil self?.notice = nil
@@ -1,94 +0,0 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("ArgsBuilder")
struct ArgsBuilderTests {
// MARK: - option
@Test("option: nil emits nothing")
func optionNil() {
#expect(ArgsBuilder.option("-f", nil) == [])
}
@Test("option: present value emits flag and value verbatim")
func optionPresent() {
#expect(ArgsBuilder.option("-f", "abc") == ["-f", "abc"])
#expect(ArgsBuilder.option("-f", "") == ["-f", ""])
#expect(ArgsBuilder.option("-f", " padded ") == ["-f", " padded "])
}
// MARK: - optionIfNonEmpty
@Test("optionIfNonEmpty: nil and empty emit nothing")
func optionIfNonEmptyNilEmpty() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", nil) == [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "") == [])
}
@Test("optionIfNonEmpty: whitespace-only emits nothing")
func optionIfNonEmptyWhitespace() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", " ") == [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " \t\n ") == [])
}
@Test("optionIfNonEmpty: trims surrounding whitespace")
func optionIfNonEmptyTrims() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", " label ") == ["-d", "label"])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n") == ["-d", "cal.cal"])
}
// MARK: - optionUnlessApprox
@Test("optionUnlessApprox: nil emits nothing")
func optionUnlessApproxNil() {
#expect(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50) == [])
}
@Test("optionUnlessApprox: exact skip value emits nothing")
func optionUnlessApproxExactSkip() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50) == [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0) == [])
}
@Test("optionUnlessApprox: within epsilon emits nothing")
func optionUnlessApproxWithinEpsilon() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50) == [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0) == [])
}
@Test("optionUnlessApprox: outside epsilon emits flag")
func optionUnlessApproxOutsideEpsilon() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50) == ["-N", "0.75"])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0) == ["-V", "1.50"])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50) == ["-N", "0.50"])
}
@Test("optionUnlessApprox: POSIX formatting is locale-stable")
func optionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal.
#expect(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0) == ["-p", "1234.50"])
#expect(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0) == ["-p", "2.00"])
}
@Test("optionUnlessApprox: custom epsilon and format honoured")
func optionUnlessApproxCustom() {
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01) == [])
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f") == ["-x", "1.5"])
}
// MARK: - flag
@Test("flag: true emits the bare flag")
func flagTrue() {
#expect(ArgsBuilder.flag("-G", when: true) == ["-G"])
#expect(ArgsBuilder.flag("-r", when: true) == ["-r"])
}
@Test("flag: false emits nothing")
func flagFalse() {
#expect(ArgsBuilder.flag("-G", when: false) == [])
#expect(ArgsBuilder.flag("-r", when: false) == [])
}
}
@@ -5,13 +5,13 @@ import Testing
@Suite("ArgyllRunner Calibration") @Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests { struct ArgyllRunnerCalibrationTests {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner { private func makeRunner() -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() .deletingLastPathComponent()
.deletingLastPathComponent() .deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin") .appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner( return ArgyllRunner(
processManager: processManager, processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir) binaryResolver: BinaryResolver(overrideDir: binDir)
) )
} }
@@ -44,9 +44,8 @@ struct ArgyllRunnerCalibrationTests {
@Test("Calibration targen from foo runs as process id targen_CAL_foo") @Test("Calibration targen from foo runs as process id targen_CAL_foo")
func calibrationTargenProcessId() async throws { func calibrationTargenProcessId() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let pm = ProcessManager() let runner = makeRunner()
let runner = makeRunner(processManager: pm) let events = ProcessManager.shared.events()
let events = pm.events()
// Subscribed before spawn; the exit event is emitted before // Subscribed before spawn; the exit event is emitted before
// runCalibrationTargen returns, so this always terminates. // runCalibrationTargen returns, so this always terminates.
let sawExit = Task { let sawExit = Task {
@@ -88,28 +87,10 @@ struct ArgyllRunnerCalibrationTests {
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("printcal failure throws toolFailed") @Test("printcal failure throws printcalFailed")
func printcalFailureThrows() async throws { func printcalFailureThrows() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
defer { try? FileManager.default.removeItem(at: testRoot) } let runner = makeRunner()
// Per-test mock printcal that always fails no global
// environment mutation, no shared fixture changes.
let binDir = try makeTestDir()
defer { try? FileManager.default.removeItem(at: binDir) }
let mockURL = binDir.appendingPathComponent("printcal")
try """
#!/bin/sh
echo "printcal mock failure" >&2
exit 1
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)
)
let output = testRoot.appendingPathComponent("CAL_demo.cal") let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "CAL_demo", ti3Basename: "CAL_demo",
@@ -117,9 +98,12 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output outputURL: output
) )
await #expect(throws: ArgyllRunnerError.toolFailed( setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) { defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
await #expect(throws: (any Error).self) {
_ = try await runner.runPrintcal(config: config) _ = try await runner.runPrintcal(config: config)
} }
try? FileManager.default.removeItem(at: testRoot)
} }
} }
@@ -33,7 +33,7 @@ struct ArgyllRunnerColprofTests {
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir) binaryResolver: BinaryResolver(overrideDir: binDir)
) )
@@ -49,32 +49,4 @@ struct ArgyllRunnerColprofTests {
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("Failing colprof throws toolFailed with code and logs")
func colprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let mockURL = dir.appendingPathComponent("colprof")
try """
#!/bin/sh
echo "colprof broke" >&2
exit 4
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)
)
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"])) {
try await runner.runColprof(config: config)
}
}
} }
@@ -1,161 +0,0 @@
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,51 +159,6 @@ struct ChartreadRowTests {
#expect(row.patchCount == 1) #expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51) #expect(row.patches[0].measured.lab?.l == 51)
} }
@Test("Decodes a row carrying both XYZ and Lab arrays")
func decodeXYZAndLab() throws {
let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [
{"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40],
"measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}}
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured
#expect(measured.xyz == CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
#expect(measured.lab == CIELab(l: 63.4, a: 2.5, b: -8.2))
}
@Test("XYZColor/CIEXYZ encode as an unkeyed three-number array")
func xyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
#expect(value as? [Double] == [1.5, 2.5, 3.5])
}
}
@Test("LabColor/CIELab encode as an unkeyed three-number array")
func labWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
#expect(value as? [Double] == [50, -1, 2])
}
}
@Test("PatchColor keeps the XYZ and Lab keys over unkeyed arrays")
func patchColorKeys() throws {
let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any]
#expect(object?["XYZ"] as? [Double] == [10, 20, 30])
#expect(object?["Lab"] as? [Double] == [55, 1, -2])
#expect(object?["spectral"] == nil)
}
} }
@Suite("ColourMath") @Suite("ColourMath")
@@ -44,29 +44,6 @@ struct PrintcalArgsTests {
]) ])
} }
@Test("Whitespace-only previous calibration path emits no -a")
func whitespacePreviousCal() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " \n\t "
)
let args = try PrintcalArgs.build(config: config)
#expect(!args.contains("-a"))
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
}
@Test("Previous calibration path is trimmed before emission")
func previousCalTrimmed() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " /tmp/old.cal "
)
let args = try PrintcalArgs.build(config: config)
#expect(args[args.firstIndex(of: "-a")! + 1] == "/tmp/old.cal")
}
@Test("Rejects invalid per-channel limit") @Test("Rejects invalid per-channel limit")
func rejectsBadChannelLimit() { func rejectsBadChannelLimit() {
let config = PrintcalConfig( let config = PrintcalConfig(
+2 -20
View File
@@ -131,23 +131,6 @@ struct PrinttargArgsTests {
#expect(!args.contains("-I")) #expect(!args.contains("-I"))
} }
@Test("Whitespace-only label emits no -d; whitespace-only calibration emits no -K/-I")
func whitespaceOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " \n ", calFile: " \t "))
#expect(!args.contains("-d"))
#expect(!args.contains("-K"))
#expect(!args.contains("-I"))
}
@Test("Label and calibration values are trimmed before emission")
func trimmedOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " My Label ", calFile: " /tmp/a.cal "))
#expect(args[args.firstIndex(of: "-d")! + 1] == "My Label")
#expect(args[args.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
}
@Test("Unsafe basename throws") @Test("Unsafe basename throws")
func unsafeBasename() { func unsafeBasename() {
#expect(throws: PathSecurity.Error.self) { #expect(throws: PathSecurity.Error.self) {
@@ -366,7 +349,7 @@ struct ArgyllRunnerPrinttargTests {
} }
} }
@Test("Non-zero exit throws toolFailed and stays on stage") @Test("Non-zero exit throws processFailed and stays on stage")
func failure() async throws { func failure() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
@@ -377,8 +360,7 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.toolFailed( await #expect(throws: ArgyllRunnerError.self) {
tool: "printtarg", code: 3, logs: ["oops"])) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
@@ -1,67 +0,0 @@
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)
}
}
+3 -33
View File
@@ -162,34 +162,6 @@ struct TargenArgsTests {
#expect(!args.contains("-p")) #expect(!args.contains("-p"))
} }
@Test("Whitespace-only preconditioning profile emits no -c")
func whitespacePreconditioner() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
preconditioningProfile: " \n\t ",
basename: "ws_pre"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("-c"))
}
@Test("Preconditioning profile is trimmed before emission")
func preconditionerTrimmed() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
preconditioningProfile: " /path/to/profile.icc ",
basename: "trim_pre"
)
let args = try TargenArgs.build(config: config)
#expect(args[args.firstIndex(of: "-c")! + 1] == "/path/to/profile.icc")
}
@Test("Invalid basename throws") @Test("Invalid basename throws")
func invalidBasenameThrows() { func invalidBasenameThrows() {
let config = TargenConfig( let config = TargenConfig(
@@ -290,7 +262,7 @@ struct ArgyllRunnerTargenTests {
#expect(ti1URL.lastPathComponent == "mock_test.ti1") #expect(ti1URL.lastPathComponent == "mock_test.ti1")
} }
@Test("Failed targen execution throws toolFailed") @Test("Failed targen execution throws processFailed")
func failedTargenExecution() async throws { func failedTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -318,8 +290,7 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.toolFailed( await #expect(throws: ArgyllRunnerError.self) {
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} }
} }
@@ -352,8 +323,7 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.missingArtefact( await #expect(throws: ArgyllRunnerError.self) {
tempDir.appendingPathComponent("no_file.ti1").path)) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} }
} }
@@ -1,42 +0,0 @@
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,18 +133,6 @@ final class Milestone2UITests: XCTestCase {
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5)) XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
} }
/// Stage 1/2 process-log containers resolve under the shared
/// `ProcessLogView` identifiers (issue #80).
func testProcessLogContainersResolve() throws {
launchApp()
XCTAssertTrue(waitFor("targenLogContainer").exists)
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
XCTAssertTrue(element("printtargLogContainer").exists)
}
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2. /// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
func testTargenFixtureUnlocksStage2() throws { func testTargenFixtureUnlocksStage2() throws {
launchApp() launchApp()
@@ -146,8 +146,6 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(notice.waitForExistence(timeout: 10)) XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "") XCTAssertTrue((notice.value as? String ?? "")
.contains("cancelled")) .contains("cancelled"))
// Cancellation is informational, never an error (#80).
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
} }
/// Preferences OK captured options are replayed verbatim in the /// Preferences OK captured options are replayed verbatim in the
@@ -216,8 +214,6 @@ final class Milestone3UITests: XCTestCase {
let notice = app.staticTexts.containing(predicate).firstMatch let notice = app.staticTexts.containing(predicate).firstMatch
XCTAssertTrue(notice.waitForExistence(timeout: 10)) XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue(notice.label.contains("Print failed")) XCTAssertTrue(notice.label.contains("Print failed"))
// Spool failure exposes the .error kind on the icon (#80).
XCTAssertEqual(element("printNotificationIcon").value as? String, "error")
} }
/// wizardState.printerName records the queue used for spooling (#95). /// wizardState.printerName records the queue used for spooling (#95).
@@ -140,57 +140,4 @@ final class Milestone4UITests: XCTestCase {
} }
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
} }
/// Two passes + a failing `average` run promote pass 1 to the
/// canonical .ti3 and show the sticky finish error notice via
/// `chartreadFinishNotice` (issue #80).
func testTwoPassAverageFailurePromotesFirstPass() throws {
app.launchEnvironment["MOCK_AVERAGE_FAIL"] = "1"
reachStage3()
app.buttons["btnDetectInstruments"].click()
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
driveOnePass(startButton: "btnStartRead")
_ = waitFor("chartreadAveragingPanel", timeout: 20)
driveOnePass(startButton: "btnMeasureAnotherSheet")
XCTAssertTrue(waitFor("btnFinishAndAverage", timeout: 20).exists)
app.buttons["btnFinishAndAverage"].click()
// Averaging failed pass 1 is promoted to the canonical .ti3
// and the sticky error notice stays on Stage 3.
let ti3 = workDir.appendingPathComponent("mytarget.ti3")
let deadline = Date().addingTimeInterval(20)
while Date() < deadline, !FileManager.default.fileExists(atPath: ti3.path) {
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
let notice = element("chartreadFinishNotice")
XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertEqual(notice.value as? String, "error")
}
/// Runs the mock handheld chartread session to completion
/// (start calibrate strip A strip B Done & Save).
private func driveOnePass(startButton: String) {
let start = app.buttons[startButton]
XCTAssertTrue(start.waitForExistence(timeout: 10))
let deadline = Date().addingTimeInterval(10)
while Date() < deadline, !start.isEnabled {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(start.isEnabled)
start.click()
_ = waitFor("btnCalibrate", timeout: 25)
app.buttons["btnCalibrate"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
_ = waitFor("btnDoneRead", timeout: 20)
app.buttons["btnDoneRead"].firstMatch.click()
}
} }
@@ -111,21 +111,4 @@ final class Milestone5UITests: XCTestCase {
"Expected verification status, got '\(statusValue)'" "Expected verification status, got '\(statusValue)'"
) )
} }
/// A failing colprof run surfaces through the session-wide wizard
/// notice only no duplicate stage-local error view (issue #80).
func testProfileFailureShowsWizardNotice() throws {
app.launchEnvironment["ICCERY_MOCK_COLPROF_EXIT"] = "2"
launchApp()
let create = waitFor("btnCreateProfile")
XCTAssertTrue(create.isEnabled)
create.click()
let notice = element("noticeText")
XCTAssertTrue(notice.waitForExistence(timeout: 20))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Profile creation failed"))
XCTAssertFalse(element("colprofLastError").exists)
}
} }
@@ -32,7 +32,6 @@ final class Milestone6CalibrationUITests: XCTestCase {
"ICCERY_TEST_WORKDIR": testWorkDir.path "ICCERY_TEST_WORKDIR": testWorkDir.path
] ]
app.launch() app.launch()
app.activate()
} }
override func tearDown() async throws { override func tearDown() async throws {
@@ -78,60 +77,4 @@ final class Milestone6CalibrationUITests: XCTestCase {
let layout = app.buttons["btnCreateLayout"] let layout = app.buttons["btnCreateLayout"]
XCTAssertTrue(layout.waitForExistence(timeout: 25)) XCTAssertTrue(layout.waitForExistence(timeout: 25))
} }
/// A failing calibration targen surfaces the error through the
/// wizard notice and restores the original basename (issue #80).
func testCalibrationTargenFailureRestoresBasename() throws {
let testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("cal-fail-\(UUID().uuidString)")
let appData = testRoot.appendingPathComponent("AppData")
try FileManager.default.createDirectory(
at: appData, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: testRoot) }
// Pre-stage wizard state so the failing mock targen is only
// exercised by the calibration run, not target generation.
let state: [String: Any] = [
"currentStage": 1,
"basename": "DemoTarget",
"cwd": testWorkDir.path,
"sessionMode": "profile",
"calibrationOriginalBasename": ""
]
let stateURL = appData.appendingPathComponent("wizard_state.json")
try JSONSerialization.data(withJSONObject: state).write(to: stateURL)
app.terminate()
app.launchEnvironment["ICCERY_TEST_ROOT"] = testRoot.path
app.launchEnvironment["ICCERY_MOCK_TARGEN_EXIT"] = "2"
app.launch()
app.activate()
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
let calGenerate = app.buttons["btnCalGenerate"]
XCTAssertTrue(calGenerate.waitForExistence(timeout: 10))
calGenerate.tap()
let notice = app.descendants(matching: .any)["noticeText"]
XCTAssertTrue(notice.waitForExistence(timeout: 20))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Calibration target failed"))
// The pre-CAL_ basename is restored and persisted.
let deadline = Date().addingTimeInterval(10)
var restoredBasename: String?
while Date() < deadline {
if let data = try? Data(contentsOf: stateURL),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let basename = object["basename"] as? String {
restoredBasename = basename
if basename == "DemoTarget" { break }
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertEqual(restoredBasename, "DemoTarget")
}
} }
+12 -12
View File
@@ -2,7 +2,7 @@ name: ICCery
options: options:
bundleIdPrefix: com.gronod bundleIdPrefix: com.gronod
deploymentTarget: deploymentTarget:
macOS: "12.0" macOS: "14.0"
groupSortPosition: top groupSortPosition: top
packages: packages:
@@ -13,7 +13,7 @@ targets:
ICCery: ICCery:
type: application type: application
platform: macOS platform: macOS
deploymentTarget: "12.0" deploymentTarget: "14.0"
sources: sources:
- path: Sources/ICCery - path: Sources/ICCery
- path: Resources - path: Resources
@@ -46,7 +46,7 @@ targets:
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: ICCery INFOPLIST_KEY_CFBundleDisplayName: ICCery
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0" MARKETING_VERSION: "2.0.0"
@@ -58,15 +58,15 @@ targets:
ENABLE_APP_SANDBOX: NO ENABLE_APP_SANDBOX: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_VERSION: "5.0" SWIFT_VERSION: "6.0"
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"] SWIFT_STRICT_CONCURRENCY: complete
MACOSX_DEPLOYMENT_TARGET: "12.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
ARCHS: "$(ARCHS_STANDARD)" ARCHS: "$(ARCHS_STANDARD)"
ICCeryCoreTests: ICCeryCoreTests:
type: bundle.unit-test type: bundle.unit-test
platform: macOS platform: macOS
deploymentTarget: "12.0" deploymentTarget: "14.0"
sources: sources:
- path: Tests/ICCeryCoreTests - path: Tests/ICCeryCoreTests
dependencies: dependencies:
@@ -79,13 +79,13 @@ targets:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery" TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "5.0" SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "12.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
ICCeryUITests: ICCeryUITests:
type: bundle.ui-testing type: bundle.ui-testing
platform: macOS platform: macOS
deploymentTarget: "12.0" deploymentTarget: "14.0"
sources: sources:
- path: Tests/ICCeryUITests - path: Tests/ICCeryUITests
dependencies: dependencies:
@@ -95,8 +95,8 @@ targets:
TEST_TARGET_NAME: ICCery TEST_TARGET_NAME: ICCery
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "5.0" SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "12.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
schemes: schemes:
ICCery: ICCery: