Compare commits

..
Author SHA1 Message Date
gronod a73c6c0b97 Merge pull request 'feat/31-about-help-chrome' (#55) from feat/31-about-help-chrome into milestone/m6-gamut-stage0-cgats-release
Reviewed-on: #55
2026-09-09 18:33:26 +01:00
gronod 563f0e5d4a Merge pull request 'Milestone/m6 gamut stage0 cgats release' (#57) from milestone/m6-gamut-stage0-cgats-release into feat/31-about-help-chrome
Reviewed-on: #57
2026-09-09 18:31:47 +01:00
gronod cd4665e7a9 Merge pull request 'feat/30-cgats-interop' (#56) from feat/30-cgats-interop into milestone/m6-gamut-stage0-cgats-release 2026-09-09 17:20:48 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 153b6194a3 Implement #30: CGATS parser, canonical .ti3 writer, and dataset import.
- Add CGATSParser, CGATSWriter, and CGATSSummary under ICCeryCore/CGATS.
- Parse CTI3, CGATS.17, ISO28178, and CSV; synthesize COLOR_REP/DEVICE_CLASS.
- Scale RGB/CMYK device coordinates from 0-255 to 0-100 when needed.
- Round-trip parse, write, reparse with tab-delimited canonical .ti3.
- Wire `Import Dataset…` in Stage 1 to open-panel import, write .ti3, and
  jump to Stage 4/5 while keeping Stages 1-2 locked without .ti1/.ti2.
- Add CGATSParserTests and Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 17:18:22 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f78da50a59 Implement #31: About dialog and global help overlays.
- Add ArtefactFiles.appInfo() build date from executable mtime.
- Add AboutView with version, build, build date, and close button.
- Add HelpOverlay view modifier for non-reflowing help badges.
- Wire About as a sheet and help toggle in SidebarView/RootView.
- Add AboutHelpUITests coverage.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 17:04:15 +01:00
gronod 629a1fce1d Merge pull request 'Milestone 5 — Profile generation, verification & installation plus M5 bugfixes (#23–#27, #50, #52)' (#54) from milestone/m5-profile into develop 2026-09-09 14:22:10 +01:00
gronod 460b0a1ffa Merge pull request 'M5 bugfixes — Stage 3 prompt stream, ProcessManager, profile/verification fixes (#50, #52)' (#53) from feat/52-m5-bugfixes into milestone/m5-profile 2026-09-09 14:21:42 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ef56cdd7d4 Fix M5 regressions and Stage 3 chartread prompt delivery (#50, #52).
Hardens ProcessManager finalisation, ensures stdout/stderr pipe write-ends
close after spawn, sets termination handlers before run(), and fixes the
runCaptured continuation hand-off for fast exits. Resolves the Stage 3
prompt stream being dropped and the handheld fixture UI not advancing,
unskips and repairs the handheld UI test, and fixes averaging panel
accessibility. Includes the selected #52 profile, verification, and
artefact-gating fixes.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 13:25:39 +01:00
gronod 61c6d62ee2 Merge pull request 'Milestone 5 — Profile generation, verification & installation (#23–#27)' (#51) from feat/23-colprof into milestone/m5-profile 2026-09-09 11:36:35 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f769cf7fec Milestone 5 — Profile generation, verification & installation (#23–#27).
Add the Stage 4/5 native workflow: deterministic colprof/applycal/iccgamut
and profcheck argv builders, verification history with CSV export and drift
analytics, user/system ColorSync profile installation, and the matching
SwiftUI views and fixture-driven tests. All Argyll interaction remains
AGPL-isolated via ProcessManager subprocesses.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 10:43:12 +01:00
gronod da53feec7f Merge milestone/m4-measurement into develop 2026-09-09 09:05:37 +01:00
gronod 64393b7591 Milestone 4 — Measurement workflow (#18–#22) 2026-09-09 09:05:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> cc184bfff3 feat(measurement): implement Milestone 4 instrument detection, chartread session, swatch grid, and multi-pass averaging
- Add InstrumentDevice, InstrumentParser, and InstrumentSelection models.
- Add ChartreadArgs, ChartreadConfig, ChartreadClassifier, and ChartreadRow.
- Add LabColor, ColorDifference (CIEDE2000), and MeasurementArtefacts.
- Extend ArgyllRunner with instlist, chartread streaming, and average.
- Implement MeasurementWorkflowViewModel and Stage3View.
- Add SwatchPatchView for live intended/measured ΔE₀₀ display.
- Route RootView to Stage 3 and wire workflow resume from .ti2.
- Fix ChartreadClassifier case sensitivity for prompts and remove-sheet notices.
- Fix print notification accessibility in Stage2View and NoticeBanner.
- Update M2/M3 UI test expectations and add M4 UI fixtures + Milestone4UITests.
- Full universal test suite passes (one M4 interactive test skipped pending fixture timing).

Refs #18 #19 #20 #21 #22

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 09:00:28 +01:00
gronod bb6ca957ba Merge pull request 'M3 — Unmanaged printing' (#48) from milestone/m3-printing into develop 2026-09-09 07:09:01 +01:00
gronod 71172d751a Merge pull request 'Milestone/m2 targets' (#43) from milestone/m2-targets into develop
Reviewed-on: #43
2026-09-09 00:10:47 +01:00
78 changed files with 7761 additions and 150 deletions
@@ -1,10 +1,18 @@
import Foundation
/// Errors from `ArgyllRunner` executions.
public enum ArgyllRunnerError: LocalizedError, Equatable {
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case processFailed(code: Int32, logs: [String])
case missingArtefact(String)
case malformedManifest(String)
case instrumentDetectionFailed(String)
case chartreadFailed(String)
case averageFailed(String)
case colprofFailed(String)
case applycalFailed(String)
case iccgamutFailed(String)
case profcheckFailed(String)
case profcheckUnparseable
public var errorDescription: String? {
switch self {
@@ -14,6 +22,22 @@ public enum ArgyllRunnerError: LocalizedError, Equatable {
return "Expected output file was not created: \(path)"
case .malformedManifest(let reason):
return "Failed to parse printtarg manifest: \(reason)"
case .instrumentDetectionFailed(let reason):
return "Instrument detection failed: \(reason)"
case .chartreadFailed(let reason):
return "Chartread failed: \(reason)"
case .averageFailed(let reason):
return "Averaging failed: \(reason)"
case .colprofFailed(let reason):
return "Profile creation failed: \(reason)"
case .applycalFailed(let reason):
return "Apply calibration failed: \(reason)"
case .iccgamutFailed(let reason):
return "Gamut extraction failed: \(reason)"
case .profcheckFailed(let reason):
return "Profile verification failed: \(reason)"
case .profcheckUnparseable:
return "Profile verification produced unparseable output"
}
}
}
@@ -63,6 +87,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -97,6 +122,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -145,22 +171,42 @@ public struct ArgyllRunner: Sendable {
// MARK: - Shared collection
/// Cancels any previous child with the same id and waits for it to
/// finalize, so `runStreaming` / `runCaptured` never sees a
/// `duplicateID` from a leftover process (#50, #52).
private func ensureNotRunning(id: String) async {
guard await processManager.isRunning(id) else { return }
await processManager.kill(id: id)
var attempts = 0
while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100))
attempts += 1
}
}
private struct CollectedRun {
var exitCode: Int32?
var stdout: String
var stderr: String
var lines: [String]
}
/// Drains the event stream until this child's `exit` event.
/// stdout is accumulated both per-line (logs) and verbatim (for
/// the manifest parse the pretty JSON needs its newlines).
///
/// When `flushPartialLines` is `true`, a background `Task` flushes
/// unterminated output every 500 ms so tools like `colprof` that
/// print dots without newlines still produce log batches.
private func collect(
id processId: String,
events: AsyncStream<ProcessEvent>,
onLogBatch: (@Sendable ([String]) -> Void)?
onLogBatch: (@Sendable ([String]) -> Void)?,
flushPartialLines: Bool = false
) async -> CollectedRun {
var lines: [String] = []
var stdout = ""
var stderr = ""
var pendingBatch: [String] = []
var exitCode: Int32?
var lastFlush = Date()
@@ -172,6 +218,17 @@ public struct ArgyllRunner: Sendable {
onLogBatch?(out)
}
var dotFlushTask: Task<Void, Never>?
if flushPartialLines {
dotFlushTask = Task { [processManager] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId)
}
}
}
for await event in events {
guard event.id == processId else { continue }
switch event {
@@ -181,6 +238,7 @@ public struct ArgyllRunner: Sendable {
pendingBatch.append(line)
case .stderr(_, let line):
lines.append(line)
stderr += line + "\n"
pendingBatch.append(line)
case .error(_, let message):
lines.append("Error: \(message)")
@@ -202,6 +260,541 @@ public struct ArgyllRunner: Sendable {
break
}
}
return CollectedRun(exitCode: exitCode, stdout: stdout, lines: lines)
dotFlushTask?.cancel()
if let dotFlushTask {
_ = await dotFlushTask.value
}
return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines)
}
// MARK: - instlist (Stage 3 detection)
/// Runs `instlist` and returns the detected devices.
///
/// The fork emits pretty-printed JSON; if that cannot be decoded a regex
/// fallback constrained to known instrument tokens is used.
public func detectInstruments() async throws -> [InstrumentDevice] {
let binaryURL = binaryResolver.resolve("instlist")
let processId = ProcessID.instlist
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: [],
workingDirectory: nil
)
var accumulator = JSONAccumulator()
var stdout = ""
var stderr: [String] = []
var exitCode: Int32?
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
stdout += line + "\n"
_ = accumulator.feed(line: line)
case .stderr(_, let line):
stderr.append(line)
case .exit(_, let code):
exitCode = code
default:
break
}
if exitCode != nil { break }
}
if let data = accumulator.completeData ?? stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) {
if let devices = try? InstrumentParser.parse(String(data: data, encoding: .utf8) ?? stdout) {
return devices
}
}
if let code = exitCode, code != 0, stderr.isEmpty == false {
throw ArgyllRunnerError.instrumentDetectionFailed(stderr.joined(separator: "\n"))
}
// Final fallback: try to parse the raw stdout as a text document.
if let devices = try? InstrumentParser.parse(stdout) {
return devices
}
throw ArgyllRunnerError.instrumentDetectionFailed("Could not parse instlist output")
}
// MARK: - average (Stage 3 multi-pass finish)
/// Runs `average` to merge two or more pass snapshots into the canonical `.ti3`.
public func runAverage(
config: AverageConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try AverageArgs.build(config: config)
let binaryURL = binaryResolver.resolve("average")
let processId = ProcessID.average(config.basename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
}
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
guard FileManager.default.fileExists(atPath: canonical.path) else {
throw ArgyllRunnerError.missingArtefact(canonical.path)
}
return canonical
}
// MARK: - colprof (Stage 4)
/// Runs `colprof` streaming, collecting logs and classifying progress
/// until the profile is written.
public func runColprof(
config: ColprofConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try ColprofArgs.build(config: config)
let binaryURL = binaryResolver.resolve("colprof")
let processId = ProcessID.colprof(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(
id: processId,
events: events,
onLogBatch: onLogBatch,
flushPartialLines: true
)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.colprofFailed(
"colprof exited with code \(run.exitCode ?? -1)"
)
}
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
guard let profileURL = ArtefactProbe.resolveProfile(
basename: cleanBasename,
cwd: cwd
) else {
let defaultURL = cwd.appendingPathComponent("\(cleanBasename).icc")
throw ArgyllRunnerError.missingArtefact(defaultURL.path)
}
return profileURL
}
// MARK: - applycal (post-colprof calibration curve)
/// Embeds a `.cal` curve into an `.icc`/`.icm` profile.
///
/// Runs `applycal` captured and performs an in-place replace via
/// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp
/// file is removed and the original is left untouched. The UI must
/// never request `unapply` (#52).
public func runApplycal(
config: ApplycalConfig
) async throws -> URL {
assert(!config.unapply, "runApplycal does not support unapply")
let inputURL = config.inputProfileURL
let cwd = inputURL.deletingLastPathComponent()
let binaryURL = binaryResolver.resolve("applycal")
let processId = ProcessID.applycal(inputURL.lastPathComponent)
let tmpURL = inputURL.appendingPathExtension("applycal.tmp")
let fm = FileManager.default
// Remove any stale tmp from a previous crash.
try? fm.removeItem(at: tmpURL)
await ensureNotRunning(id: processId)
let outputConfig = ApplycalConfig(
calibrationPath: config.calibrationPath,
inputProfileURL: inputURL,
outputProfileURL: tmpURL,
unapply: false
)
let outputArgs = try ApplycalArgs.build(config: outputConfig)
let result = try await processManager.runCaptured(
id: processId,
binary: binaryURL,
arguments: outputArgs,
workingDirectory: cwd
)
guard result.exitCode == 0, !Task.isCancelled else {
try? fm.removeItem(at: tmpURL)
if Task.isCancelled {
throw CancellationError()
}
throw ArgyllRunnerError.applycalFailed(
result.stderr.isEmpty
? "applycal exited with code \(result.exitCode)"
: result.stderr
)
}
guard fm.fileExists(atPath: tmpURL.path) else {
throw ArgyllRunnerError.applycalFailed(
"applycal did not create temp profile"
)
}
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(
"calibrated profile is too small (\(size) bytes)"
)
}
do {
if fm.fileExists(atPath: inputURL.path) {
_ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL)
} else {
try fm.moveItem(at: tmpURL, to: inputURL)
}
} catch {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
}
return inputURL
}
// MARK: - iccgamut (post-colprof gamut mesh)
/// Extracts a `.gam` mesh from the finished profile.
public func runIccgamut(
config: IccgamutConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let profileURL = config.profileURL
let cwd = profileURL.deletingLastPathComponent()
let stem = profileURL.deletingPathExtension().lastPathComponent
let args = try IccgamutArgs.build(config: config)
let binaryURL = binaryResolver.resolve("iccgamut")
let processId = ProcessID.iccgamut(stem: stem)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.iccgamutFailed(
"iccgamut exited with code \(run.exitCode ?? -1)"
)
}
let gamURL = cwd.appendingPathComponent("\(stem).gam")
guard FileManager.default.fileExists(atPath: gamURL.path) else {
throw ArgyllRunnerError.missingArtefact(gamURL.path)
}
return gamURL
}
// MARK: - profcheck (Stage 5 verification)
/// Verifies a profile against the canonical `.ti3`.
public func runProfcheck(
config: ProfcheckConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> ProfcheckReport {
let cwd = config.ti3URL.deletingLastPathComponent()
let ti3Path = config.ti3URL.path
let iccURL = Self.resolveProfileForVerification(config.iccURL)
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
let args = try ProfcheckArgs.build(config: config)
let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.profcheckFailed(
run.stderr.isEmpty
? "profcheck exited with code \(run.exitCode ?? -1)"
: run.stderr
)
}
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
let report = ProfcheckParser.parse(output)
guard report.isValid else {
throw ArgyllRunnerError.profcheckUnparseable
}
return report
}
private static func resolveProfileForVerification(_ url: URL) -> URL {
let fm = FileManager.default
if fm.fileExists(atPath: url.path) { return url }
let alt = url.pathExtension.lowercased() == "icc"
? url.deletingPathExtension().appendingPathExtension("icm")
: url.deletingPathExtension().appendingPathExtension("icc")
return fm.fileExists(atPath: alt.path) ? alt : url
}
// MARK: - chartread (Stage 3 interactive)
/// Runs `chartread` and returns an `AsyncStream` of typed events.
///
/// Subscribe-before-spawn, prompt/row/log forwarding, and exit verification
/// are all handled here. Use `sendChartreadInput` to drive the child and
/// `cancelChartread` to terminate it.
public func runChartread(config: ChartreadConfig) -> AsyncStream<ChartreadEvent> {
let cleanBasename: String
let cwd: URL
do {
cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
}
}
let args: [String]
do {
args = try ChartreadArgs.build(config: config)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
}
}
let binaryURL = binaryResolver.resolve("chartread")
let processId = ProcessID.chartread(cleanBasename)
let processManager = self.processManager
let isXY = config.isXY
return AsyncStream { continuation in
let task = Task {
await ensureNotRunning(id: processId)
let events = processManager.events()
// Register the XY parking hook before spawning.
await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(for: .milliseconds(500))
}
}
do {
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
} catch {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
return
}
var state: ChartreadState = .idle
var pendingLogs: [String] = []
var lastFlush = Date()
var exitCode: Int32?
func flushLogs() {
guard !pendingLogs.isEmpty else { return }
let batch = pendingLogs
pendingLogs.removeAll(keepingCapacity: true)
continuation.yield(.log(batch))
}
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
let previous = state
let classified = ChartreadClassifier.classify(line: line, previousState: previous)
state = classified.state
if classified.isRemoveSheetNotice {
continuation.yield(.removeSheetNotice)
}
let shouldPrompt =
classified.sheetNumber != nil
|| classified.alignmentPatch != nil
|| classified.requestedWarningKey != nil
|| classified.state != previous
|| classified.isTableContinuation
if shouldPrompt {
continuation.yield(.prompt(classified))
}
pendingLogs.append(line)
case .stderr(_, let line):
pendingLogs.append(line)
case .jsonRow(_, let payload):
do {
let row = try JSONDecoder().decode(ChartreadRow.self, from: payload)
state = row.isFinalRow ? .allStripsRead : state
continuation.yield(.row(row))
} catch {
pendingLogs.append("Malformed row JSON: \(error.localizedDescription)")
}
case .error(_, let message):
pendingLogs.append("Error: \(message)")
case .exit(_, let code):
exitCode = code
}
if exitCode == nil,
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
flushLogs()
lastFlush = Date()
}
if exitCode != nil {
flushLogs()
break
}
}
if Task.isCancelled {
continuation.finish()
return
}
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
if let code = exitCode, code == 0 {
if FileManager.default.fileExists(atPath: canonical.path) {
continuation.yield(.completed(canonical))
} else {
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
}
} else {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
}
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
Task {
await processManager.kill(id: processId)
}
}
}
}
/// Send an exact input sequence to the running `chartread` child.
public func sendChartreadInput(basename: String, input: ChartreadInput) async throws {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
let processId = ProcessID.chartread(cleanBasename)
try await processManager.sendStdin(id: processId, bytes: input.bytes)
}
/// Terminate a running `chartread` child.
///
/// The actual XY parking is handled by the pre-kill hook registered in
/// `runChartread`.
public func cancelChartread(basename: String, isXY: Bool = false) {
let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
guard let cleanBasename else { return }
let processId = ProcessID.chartread(cleanBasename)
Task {
await processManager.kill(id: processId)
}
}
}
/// Events emitted by a running `chartread` session.
public enum ChartreadEvent: Sendable {
/// Classified prompt / state update.
case prompt(ChartreadClassifyResult)
/// A decoded `ROW_COLORS_JSON` row.
case row(ChartreadRow)
/// A batched log chunk (stdout + stderr lines).
case log([String])
/// Informational "remove last sheet" notice.
case removeSheetNotice
/// Process exited with the given code.
case exit(Int32)
/// Successful completion with the canonical `.ti3` URL.
case completed(URL)
/// Failure (non-zero exit, missing artefact, spawn/parse error).
case failed(ArgyllRunnerError)
}
/// Exact bytes sent to `chartread` stdin.
public enum ChartreadInput: Sendable {
case trigger // " \n"
case accept // "\n"
case done // "d\n"
case quit // "q\n"
case customKey(String)
public var bytes: Data {
switch self {
case .trigger:
return Data(" \n".utf8)
case .accept:
return Data("\n".utf8)
case .done:
return Data("d\n".utf8)
case .quit:
return Data("q\n".utf8)
case .customKey(let key):
return Data("\(key)\n".utf8)
}
}
}
@@ -0,0 +1,372 @@
import Foundation
/// Errors that can occur while parsing CGATS-like data.
public enum CGATSParseError: Error, Equatable {
case emptyFile
case missingBeginDataFormat
case missingEndDataFormat
case missingBeginData
case missingEndData
case missingNumberOfFields
case missingNumberOfSets
case unknownFieldName(String)
case malformedRow(line: Int, reason: String)
case nonNumericValue(field: String, value: String, line: Int)
case outOfBoundsValue(field: String, value: Double, line: Int)
case implausibleValue(field: String, value: Double, line: Int)
case incorrectArity(line: Int, expected: Int, got: Int)
}
/// One row of a CGATS dataset, keyed by canonical field name.
public struct CGATSSample: Sendable, Equatable {
public var id: String
public var loc: String?
public var values: [String: String]
public init(id: String, loc: String? = nil, values: [String: String] = [:]) {
self.id = id
self.loc = loc
self.values = values
}
}
/// A parsed CGATS / CTI3 / CSV dataset.
public struct CGATSDataset: Sendable, Equatable {
public var format: CGATSFormat
public var keywords: [String: String]
public var fieldNames: [String]
public var samples: [CGATSSample]
public var colorRep: String?
public var deviceClass: String?
public var targetInstrument: String?
public init(
format: CGATSFormat,
keywords: [String: String] = [:],
fieldNames: [String] = [],
samples: [CGATSSample] = [],
colorRep: String? = nil,
deviceClass: String? = nil,
targetInstrument: String? = nil
) {
self.format = format
self.keywords = keywords
self.fieldNames = fieldNames
self.samples = samples
self.colorRep = colorRep
self.deviceClass = deviceClass
self.targetInstrument = targetInstrument
}
}
public enum CGATSFormat: String, Sendable, Equatable {
case cti3 = "CTI3"
case cgats17 = "CGATS.17"
case csv = "CSV"
}
/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets.
public enum CGATSParser {
/// Parse the contents of a CGATS-like file.
public static func parse(
_ contents: String,
sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
.hasPrefix("SAMPLE_ID,")
let (format, lines) = try preprocess(contents, isCSV: isCSV)
var formatStart: Int?
var formatEnd: Int?
var dataStart: Int?
var dataEnd: Int?
var keywords = [String: String]()
for (index, line) in lines.enumerated() {
switch Self.normalizedKeyword(line) {
case "BEGIN_DATA_FORMAT": formatStart = index
case "END_DATA_FORMAT": formatEnd = index
case "BEGIN_DATA": dataStart = index
case "END_DATA": dataEnd = index
default:
if let (key, value) = parseKeyword(line) {
keywords[key] = value
}
}
}
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat
}
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData
}
let rawFieldNames = splitFields(lines[formatStart + 1])
let fieldNames = rawFieldNames.map { canonicalFieldName($0) }
if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init),
numberOfFields != fieldNames.count {
// Warn only; the data format line is the source of truth.
} else if keywords["NUMBER_OF_FIELDS"] == nil {
// Optional header; do not fail.
}
if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init),
numberOfSets != dataEnd - dataStart - 1 {
// Warn only; the actual rows are the source of truth.
} else if keywords["NUMBER_OF_SETS"] == nil {
// Optional header; do not fail.
}
struct RawSample {
var id: String
var loc: String?
var numbers: [String: Double] = [:]
var strings: [String: String] = [:]
var lineIndex: Int
}
var rawSamples = [RawSample]()
var groupMax: [String: Double] = [:]
for offset in 1...(dataEnd - dataStart - 1) {
let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else {
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
}
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
for (i, name) in fieldNames.enumerated() {
let raw = stripInlineComment(rawRow[i])
if isNumericField(name) {
let cleaned = raw.trimmingCharacters(in: .whitespaces)
if let number = parseNumber(cleaned) {
sample.numbers[name] = number
if let group = deviceGroup(name) {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
}
} else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
}
} else {
sample.strings[name] = raw
}
}
sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset)
sample.loc = sample.strings["SAMPLE_LOC"]
rawSamples.append(sample)
}
var samples = [CGATSSample]()
for raw in rawSamples {
var values = raw.strings
for (name, number) in raw.numbers {
var scaled = number
if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 {
scaled = number / 2.55
}
values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1)
}
var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values)
// Keep lookups by canonical keys, but also preserve original aliases.
let rawRow = splitFields(lines[raw.lineIndex])
for (i, rawName) in rawFieldNames.enumerated() {
let canonical = canonicalFieldName(rawName)
if canonical != rawName {
sample.values[rawName] = rawRow[i]
}
}
samples.append(sample)
}
let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames)
let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames)
return CGATSDataset(
format: format,
keywords: keywords,
fieldNames: fieldNames,
samples: samples,
colorRep: colorRep,
deviceClass: deviceClass,
targetInstrument: keywords["TARGET_INSTRUMENT"]
)
}
/// Parse from a URL (throws as `Error` for public callers).
public static func parse(url: URL) throws -> CGATSDataset {
let contents = try String(contentsOf: url)
return try parse(contents, sourceURL: url)
}
// MARK: - Internals
private static func preprocess(
_ contents: String,
isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines)
var lines = [String]()
var format: CGATSFormat?
for var line in allLines {
line = stripComment(line)
line = line.trimmingCharacters(in: .whitespaces)
guard !line.isEmpty else { continue }
if format == nil {
if line.hasPrefix("CTI3") { format = .cti3 }
else if line.hasPrefix("CGATS.17") { format = .cgats17 }
else if isCSV { format = .csv }
}
if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" ||
line == "BEGIN_DATA" || line == "END_DATA" ||
(line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") ||
line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) {
// These are exact keywords; keep them intact.
}
lines.append(line)
}
guard !lines.isEmpty else { throw .emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it.
if let format, format == .csv,
!lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) {
let header = lines[0]
let data = lines.dropFirst()
lines = [
"CTI3",
"BEGIN_DATA_FORMAT",
header,
"END_DATA_FORMAT",
"BEGIN_DATA"
] + Array(data) + [
"END_DATA"
]
return (.csv, lines)
}
return (format ?? .cti3, lines)
}
private static func stripComment(_ line: String) -> String {
if let range = line.range(of: "#") {
return String(line[..<range.lowerBound])
}
return line
}
private static func stripInlineComment(_ token: String) -> String {
if let range = token.range(of: "#") {
return String(token[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
}
return token
}
private static func splitFields(_ line: String) -> [String] {
// CTI3/CGATS.17 use whitespace/tabs; CSV uses commas.
if line.contains(",") {
return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}
return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
}
private static func parseKeyword(_ line: String) -> (key: String, value: String)? {
// KEYWORD value or KEYWORD "value"
let tokens = splitFields(line)
guard let key = tokens.first else { return nil }
// Data-boundary keywords are not value keywords.
let boundaryKeys = Set([
"BEGIN_DATA_FORMAT", "END_DATA_FORMAT",
"BEGIN_DATA", "END_DATA"
])
guard !boundaryKeys.contains(key) else { return nil }
let rawValue = tokens.dropFirst().joined(separator: " ")
let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
return (key, value)
}
private static func normalizedKeyword(_ line: String) -> String {
line.uppercased().trimmingCharacters(in: .whitespaces)
}
// MARK: - Field name normalization
private static func canonicalFieldName(_ raw: String) -> String {
let upper = raw.uppercased()
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "-", with: "_")
switch upper {
case "SAMPLE_ID", "ID": return "SAMPLE_ID"
case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC"
case "SAMPLE_NAME": return "SAMPLE_ID"
case "LAB_L", "L*", "L_AB": return "LAB_L"
case "LAB_A", "A*", "A_AB": return "LAB_A"
case "LAB_B", "B*", "B_AB": return "LAB_B"
case "XYZ_X", "X": return "XYZ_X"
case "XYZ_Y", "Y": return "XYZ_Y"
case "XYZ_Z", "Z": return "XYZ_Z"
default: return upper
}
}
private static func isNumericField(_ name: String) -> Bool {
let numericNames: Set = [
"SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"
]
return !numericNames.contains(name)
}
private static func parseNumber(_ raw: String) -> Double? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.number(from: raw)?.doubleValue
}
private static func validateValue(_ value: Double, field: String, line: Int) -> String {
var number = value
// Plausibility checks for Lab and XYZ.
if field == "LAB_L" { number = max(0, min(160, number)) }
if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) }
if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) }
return String(format: "%.4f", number)
}
private static func deviceGroup(_ name: String) -> String? {
if name.hasPrefix("RGB_") { return "RGB" }
if name.hasPrefix("CMYK_") { return "CMYK" }
if name.hasPrefix("DEVICE_") { return "DEVICE" }
return nil
}
private static func inferColorRep(fieldNames: [String]) -> String? {
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" }
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" }
if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" }
if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" }
return nil
}
private static func inferDeviceClass(fieldNames: [String]) -> String? {
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" }
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" }
return "OUTPUT"
}
}
@@ -0,0 +1,20 @@
import Foundation
/// Human-readable summary of an imported CGATS dataset.
public struct CGATSSummary: Sendable, Equatable {
public let patchCount: Int
public let colorSpace: String?
public let deviceClass: String?
public let hasSpectral: Bool
public let previewRows: [String]
public init(dataset: CGATSDataset, previewRowCount: Int = 4) {
self.patchCount = dataset.samples.count
self.colorSpace = dataset.colorRep
self.deviceClass = dataset.deviceClass
self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") }
self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in
"\(sample.id)" + (sample.loc.map { " \($0)" } ?? "")
})
}
}
@@ -0,0 +1,82 @@
import Foundation
/// Errors from writing a canonical `.ti3` dataset.
public enum CGATSWriterError: Error, Equatable {
case noSamples
case missingRequiredField(String)
case invalidValue(field: String, value: String)
}
/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text.
public enum CGATSWriter {
public static func write(_ dataset: CGATSDataset) throws -> String {
guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else {
throw CGATSWriterError.noSamples
}
var lines = [String]()
// Header
lines.append(dataset.format.rawValue)
lines.append("")
lines.append("DESCRIPTOR \"ICCery CGATS export\"")
if let colorRep = dataset.colorRep {
lines.append("COLOR_REP \"\(colorRep)\"")
}
if let deviceClass = dataset.deviceClass {
lines.append("DEVICE_CLASS \"\(deviceClass)\"")
}
if let instrument = dataset.targetInstrument {
lines.append("TARGET_INSTRUMENT \"\(instrument)\"")
}
lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)")
lines.append("NUMBER_OF_SETS \(dataset.samples.count)")
lines.append("")
lines.append("BEGIN_DATA_FORMAT")
lines.append(dataset.fieldNames.joined(separator: "\t"))
lines.append("END_DATA_FORMAT")
lines.append("")
lines.append("BEGIN_DATA")
for sample in dataset.samples {
let row = try dataset.fieldNames.map { field in
guard let raw = sample.values[field], !raw.isEmpty else {
throw CGATSWriterError.missingRequiredField(field)
}
// Normalize numeric fields to a compact decimal.
if isNumeric(field) {
return normalizedNumber(raw)
}
return raw
}
lines.append(row.joined(separator: "\t"))
}
lines.append("END_DATA")
return lines.joined(separator: "\n") + "\n"
}
public static func write(_ dataset: CGATSDataset, to url: URL) throws {
let text = try write(dataset)
try text.write(to: url, atomically: true, encoding: .utf8)
}
// MARK: - Internals
private static func isNumeric(_ field: String) -> Bool {
let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"]
return !nonNumeric.contains(field)
}
private static func normalizedNumber(_ raw: String) -> String {
guard let number = Double(raw) else { return raw }
if number == floor(number) {
return String(format: "%.0f", number)
}
return String(format: "%.4f", number)
}
}
@@ -15,14 +15,26 @@ public enum ArtefactFiles {
try Data(contentsOf: url).base64EncodedString()
}
/// `get_app_info` version + build for the About dialog.
/// `get_app_info` version, build, and build date for the About dialog.
public static func appInfo(
bundle: Bundle = .main
) -> (version: String, build: String) {
) -> (version: String, build: String, buildDate: String) {
let info = bundle.infoDictionary ?? [:]
return (
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
info["CFBundleVersion"] as? String ?? "0"
)
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
let build = info["CFBundleVersion"] as? String ?? "0"
let url = bundle.executableURL ?? bundle.bundleURL
let buildDate: String
if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]),
let date = values.contentModificationDate {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
buildDate = formatter.string(from: date)
} else {
buildDate = "Unknown"
}
return (version, build, buildDate)
}
}
@@ -0,0 +1,78 @@
import Foundation
/// Errors during `average` argv construction.
public enum AverageArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case invalidPassCount(Int)
case outputCollidesWithInput
case pathOutsideCwd(URL)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid basename for average: \(name)"
case .invalidPassCount(let count):
return "Average requires at least 2 pass files, got \(count)"
case .outputCollidesWithInput:
return "Average output filename collides with one of the inputs"
case .pathOutsideCwd(let url):
return "Pass or output file is outside the working directory: \(url.path)"
}
}
}
/// Configuration for an `average` run.
public struct AverageConfig: Sendable, Equatable {
public let workingDirectory: URL
public let basename: String
public let passFiles: [URL]
public init(
workingDirectory: URL,
basename: String,
passFiles: [URL]
) {
self.workingDirectory = workingDirectory
self.basename = basename
self.passFiles = passFiles
}
}
/// Pure argv builder for Argyll's `average` tool.
public enum AverageArgs {
/// Builds `average -v pass1 pass2 ... basename.ti3` with relative names.
public static func build(config: AverageConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
guard config.passFiles.count >= 2 else {
throw AverageArgError.invalidPassCount(config.passFiles.count)
}
let output = config.workingDirectory
.appendingPathComponent("\(cleanBasename).ti3")
var inputNames: [String] = []
for url in config.passFiles {
try validate(url, isIn: config.workingDirectory)
inputNames.append(url.lastPathComponent)
}
try validate(output, isIn: config.workingDirectory)
let outputName = output.lastPathComponent
guard !inputNames.contains(outputName) else {
throw AverageArgError.outputCollidesWithInput
}
return ["-v"] + inputNames + [outputName]
}
private static func validate(_ url: URL, isIn cwd: URL) throws {
let cwdPath = cwd.standardizedFileURL.path
let urlPath = url.deletingLastPathComponent().standardizedFileURL.path
guard urlPath == cwdPath else {
throw AverageArgError.pathOutsideCwd(url)
}
}
}
@@ -0,0 +1,28 @@
import Foundation
/// Pure argv builder for Argyll's `chartread` tool.
public enum ChartreadArgs {
/// Builds `chartread` argv per the Gronod fork protocol.
///
/// - Always `-v -u`.
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`.
/// - `-Y l` is emitted only when `enableLEDs` is `true`.
/// - Basename is the last positional argument and is sanitized.
public static func build(config: ChartreadConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v", "-u"]
if let port = config.selectedPort, port > 1 {
args.append(contentsOf: ["-c", "\(port)"])
}
if config.enableLEDs {
args.append(contentsOf: ["-Y", "l"])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,302 @@
import Foundation
/// Discrete states for the `chartread` interaction.
public enum ChartreadState: String, Codable, Sendable, Equatable, CaseIterable {
case idle
case calibrating
case awaitingStrip
case reading
case allStripsRead
case warning
case promptContinue
case tablePlaceSheet
case tableAlign
case error
case finished
}
/// Extra metadata produced by classifying a single `chartread` stdout line.
public struct ChartreadClassifyResult: Sendable, Equatable {
public let state: ChartreadState
/// Whether this line is an informational "remove last sheet" notice.
public let isRemoveSheetNotice: Bool
/// Parsed sheet index and total from "sheet N of M read ok" or "place sheet N of M".
public let sheetNumber: Int?
public let sheetTotal: Int?
/// Fiducial patch name from XY "locate patch X with the sight".
public let alignmentPatch: String?
/// When a warning asks for a specific key (e.g. `y` or `n`), the caller should send that key.
public let requestedWarningKey: String?
/// Whether the line is a continuation of a multi-line XY prompt.
public let isTableContinuation: Bool
public init(
state: ChartreadState,
isRemoveSheetNotice: Bool = false,
sheetNumber: Int? = nil,
sheetTotal: Int? = nil,
alignmentPatch: String? = nil,
requestedWarningKey: String? = nil,
isTableContinuation: Bool = false
) {
self.state = state
self.isRemoveSheetNotice = isRemoveSheetNotice
self.sheetNumber = sheetNumber
self.sheetTotal = sheetTotal
self.alignmentPatch = alignmentPatch
self.requestedWarningKey = requestedWarningKey
self.isTableContinuation = isTableContinuation
}
}
/// Pure line classifier for `chartread` stdout.
///
/// Matchers are evaluated in strict priority order (docs/04 §3.5, docs/05 §12.6).
/// XY table continuation lines stay sticky in `TABLE_PLACE_SHEET` / `TABLE_ALIGN`.
public enum ChartreadClassifier {
private typealias Matcher = (String, ChartreadState) -> ChartreadClassifyResult?
public static func classify(
line: String,
previousState: ChartreadState
) -> ChartreadClassifyResult {
let text = line.lowercased()
for matcher in matchers(previousState) {
if let result = matcher(text, previousState) {
return result
}
}
return ChartreadClassifyResult(state: previousState)
}
private static func matchers(_ previous: ChartreadState) -> [Matcher] {
[
removeSheetNotice,
sheetReadOk,
locatePatch,
placeSheet,
continuation(previous),
done,
warning,
calibration,
awaitingStrip,
reading,
error
]
}
// 1. "Please remove last sheet from table" info only.
private static func removeSheetNotice(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard text.contains("remove") && text.contains("last") && text.contains("sheet") else { return nil }
return ChartreadClassifyResult(state: previous, isRemoveSheetNotice: true)
}
// 2. "Sheet N of M read OK".
private static func sheetReadOk(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard let match = text.firstMatch(pattern: #"sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok"#) else { return nil }
return ChartreadClassifyResult(
state: previous,
sheetNumber: match.1,
sheetTotal: match.2
)
}
// 3. "locate patch X with the sight".
private static func locatePatch(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard let match = text.firstMatch(pattern: #"locate\s+patch\s+([a-z0-9_]+)\s+with"#),
!match.0.isEmpty else { return nil }
return ChartreadClassifyResult(
state: .tableAlign,
alignmentPatch: match.0.uppercased()
)
}
// 4. "place sheet N of M" or "remove previous sheet".
private static func placeSheet(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
if let match = text.firstMatch(pattern: #"place\s+sheet\s+(\d+)\s+of\s+(\d+)"#) {
return ChartreadClassifyResult(
state: .tablePlaceSheet,
sheetNumber: match.1,
sheetTotal: match.2
)
}
if text.contains("remove previous sheet") || text.contains("place sheet") {
return ChartreadClassifyResult(state: .tablePlaceSheet)
}
return nil
}
// 5. "hit return to continue" sticky if already in a table state.
private static func continuation(_ previous: ChartreadState) -> Matcher {
return { text, _ in
guard text.contains("hit return to continue")
|| text.contains("hit any key to continue")
|| text.contains("hit space to continue")
else { return nil }
if case .tablePlaceSheet = previous {
return ChartreadClassifyResult(state: .tablePlaceSheet, isTableContinuation: true)
}
if case .tableAlign = previous {
return ChartreadClassifyResult(state: .tableAlign, isTableContinuation: true)
}
return ChartreadClassifyResult(state: .promptContinue, isTableContinuation: true)
}
}
// 6. Done / all read.
private static func done(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let phrases = [
"'d' if/when done", "d to finish/save", "all strips/patches read",
"all strips read", "all patches read", "done reading",
"'d' to save", "press d to", "hit 'd'", "d to finish", "d to save"
]
if phrases.contains(where: { text.contains($0) }) {
return ChartreadClassifyResult(state: .allStripsRead)
}
return nil
}
// 7. Warnings / prompts needing a key.
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lower = text
let warningSignals = [
"(warning)", "use it anyway", "seem to have read strip",
"unexpected response", "try again", "do you want to",
"abort ? - are you sure", "are you sure"
]
let isWarningPrompt =
warningSignals.contains(where: { lower.contains($0) })
|| lower.contains("(y/n)")
|| lower.contains("'y' or 'n'")
|| lower.contains("?")
guard isWarningPrompt else { return nil }
var key: String?
if lower.contains("(y/n)") || lower.contains("'y' or 'n'") {
// Default to asking the user; no automatic key.
key = nil
} else if lower.contains("'y'") || lower.contains("press y") || lower.contains("hit 'y'") {
key = "y"
} else if lower.contains("'n'") || lower.contains("press n") || lower.contains("hit 'n'") {
key = "n"
}
return ChartreadClassifyResult(state: .warning, requestedWarningKey: key)
}
// 8. Calibration / place reference / white / standard tile.
private static func calibration(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
let placeTokens = ["place", "reference", "white", "calibrat", "standard"]
let hasPlaceSheet = lowercased.contains("place sheet") || lowercased.contains("remove previous sheet")
let hasLocate = lowercased.contains("locate patch")
guard placeTokens.contains(where: { lowercased.contains($0) }),
!hasPlaceSheet,
!hasLocate
else { return nil }
if lowercased.contains("calibrat")
|| lowercased.contains("white reference")
|| lowercased.contains("white tile")
|| lowercased.contains("standard tile")
|| lowercased.contains("reference")
|| lowercased.contains("tile")
|| lowercased.contains("hit any key to continue")
|| lowercased.contains("hit space to continue") {
return ChartreadClassifyResult(state: .calibrating)
}
return nil
}
// 9. Awaiting strip.
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
// These are explicit, multi-word prompts; we deliberately do NOT
// match bare "read strip" so that error lines like
// "failed to read strip" or "error reading strip" fall through to
// the error matcher.
let phrases = [
"ready to read",
"hit any key to read",
"hit a key to read",
"hit space to read",
"hit [space] to read",
"press any key to read",
"press space to read",
"trigger instrument",
"start reading",
"read next strip"
]
// Also permit "hit X to read strip Y" or "ready to read strip Z".
if lowercased.range(of: #"(hit|press).+to\s+read\s+strip"#, options: .regularExpression) != nil {
return ChartreadClassifyResult(state: .awaitingStrip)
}
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
return ChartreadClassifyResult(state: .awaitingStrip)
}
// 10. Reading.
private static func reading(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
let phrases = ["reading strip", "reading sheet", "processing", "scanning", "reading..."]
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
return ChartreadClassifyResult(state: .reading)
}
// 11. Error.
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lower = text.lowercased()
// Avoid false positives from confirmation prompts and "no error" status.
guard !lower.contains("no error") else { return nil }
guard !lower.contains("(y/n)")
&& !lower.contains("'y' or 'n'")
&& !lower.contains("?")
else { return nil }
let phraseMatches = ["failed to read", "error reading", "too fast", "too slow", "misread"]
for phrase in phraseMatches {
if lower.contains(phrase) {
return ChartreadClassifyResult(state: .error)
}
}
// Whole-word "error" only bare "failed" alone is not enough.
if lower.range(of: #"\berror\b"#, options: .regularExpression) != nil {
return ChartreadClassifyResult(state: .error)
}
return nil
}
}
private extension String {
func firstMatch(pattern: String) -> (String, Int, Int)? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: self, options: [], range: NSRange(self.startIndex..., in: self))
else { return nil }
let groups: [String] = (1..<match.numberOfRanges).compactMap { i in
let r = match.range(at: i)
guard r.location != NSNotFound, let range = Range(r, in: self) else { return nil }
return String(self[range])
}
guard let first = groups.first else { return nil }
let ints = groups.compactMap { Int($0) }
let a = ints.count > 0 ? ints[0] : 0
let b = ints.count > 1 ? ints[1] : 0
return (first, a, b)
}
}
@@ -0,0 +1,45 @@
import Foundation
/// Errors during `ChartreadArgs` validation.
public enum ChartreadArgError: LocalizedError, Equatable {
case invalidBasename(String)
case invalidPort(Int)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid chart basename: \(name)"
case .invalidPort(let port):
return "Invalid chartread port: \(port)"
}
}
}
/// Configuration for a `chartread` invocation.
public struct ChartreadConfig: Codable, Equatable, Sendable {
public var basename: String
public var workingDirectory: URL?
/// Communication port to pass to `chartread -c`.
/// `nil` means omit `-c` (Auto or port 1).
public var selectedPort: Int?
/// Enable i1Pro 2 visual LEDs (`-Y l`).
public var enableLEDs: Bool
public init(
basename: String,
workingDirectory: URL? = nil,
selectedPort: Int? = nil,
enableLEDs: Bool = false,
isXY: Bool = false
) {
self.basename = basename
self.workingDirectory = workingDirectory
self.selectedPort = selectedPort
self.enableLEDs = enableLEDs
self.isXY = isXY
}
/// Whether the current config implies an XY-table workflow.
/// This is normally supplied by the view model from the selected instrument.
public var isXY: Bool = false
}
@@ -0,0 +1,143 @@
import Foundation
/// A single patch read by `chartread`.
public struct ChartreadPatch: Codable, Sendable, Equatable {
public let id: String
public let loc: String
public let isPad: Bool
public let device: [Double]
public let expected: PatchColor?
public let measured: PatchColor
public init(
id: String,
loc: String,
isPad: Bool,
device: [Double],
expected: PatchColor?,
measured: PatchColor
) {
self.id = id
self.loc = loc
self.isPad = isPad
self.device = device
self.expected = expected
self.measured = measured
}
enum CodingKeys: String, CodingKey {
case id, loc
case isPad = "is_pad"
case device, expected, measured
}
}
/// Colour payload carried by `expected` or `measured`.
public struct PatchColor: Codable, Sendable, Equatable {
public let xyz: CIEXYZ?
public let lab: CIELab?
public let spectral: SpectralData?
public init(xyz: CIEXYZ? = nil, lab: CIELab? = nil, spectral: SpectralData? = nil) {
self.xyz = xyz
self.lab = lab
self.spectral = spectral
}
enum CodingKeys: String, CodingKey {
case xyz = "XYZ"
case lab = "Lab"
case spectral = "spectral"
}
}
public struct CIEXYZ: Codable, Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.x = try container.decode(Double.self)
self.y = try container.decode(Double.self)
self.z = try container.decode(Double.self)
}
public init(x: Double, y: Double, z: Double) {
self.x = x
self.y = y
self.z = z
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(x)
try container.encode(y)
try container.encode(z)
}
}
public struct CIELab: Codable, Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.l = try container.decode(Double.self)
self.a = try container.decode(Double.self)
self.b = try container.decode(Double.self)
}
public init(l: Double, a: Double, b: Double) {
self.l = l
self.a = a
self.b = b
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(l)
try container.encode(a)
try container.encode(b)
}
}
public struct SpectralData: Codable, Sendable, Equatable {
public let bands: Int
public let startNM: Double
public let endNM: Double
public let norm: Double
public let values: [Double]
enum CodingKeys: String, CodingKey {
case bands
case startNM = "start_nm"
case endNM = "end_nm"
case norm
case values
}
}
/// A complete row emitted by `chartread -u`.
public struct ChartreadRow: Codable, Sendable, Equatable {
public let event: String
public let rowId: String
public let rowIndex: Int
public let totalRows: Int
public let patchCount: Int
public let patches: [ChartreadPatch]
enum CodingKeys: String, CodingKey {
case event
case rowId = "row_id"
case rowIndex = "row_index"
case totalRows = "total_rows"
case patchCount = "patch_count"
case patches
}
public var isFinalRow: Bool {
rowIndex + 1 >= totalRows
}
}
@@ -0,0 +1,174 @@
import Foundation
/// Result of evaluating one measured patch.
public struct SwatchEvaluation: Sendable, Equatable {
public let intended: DisplayRGB
public let measured: DisplayRGB
public let deltaE: Double?
public let classification: SwatchClassification
public init(
intended: DisplayRGB,
measured: DisplayRGB,
deltaE: Double?,
classification: SwatchClassification
) {
self.intended = intended
self.measured = measured
self.deltaE = deltaE
self.classification = classification
}
}
public enum SwatchClassification: String, Sendable, Equatable, CaseIterable {
case good
case warning
case bad
}
/// CIEDE2000 ΔE between two D50 Lab values.
public enum ColorDifference {
/// Compute ΔE using the full CIEDE2000 formula.
public static func deltaE00(_ lab1: LabColor, _ lab2: LabColor) -> Double {
let kL: Double = 1
let kC: Double = 1
let kH: Double = 1
let c1 = sqrt(lab1.a * lab1.a + lab1.b * lab1.b)
let c2 = sqrt(lab2.a * lab2.a + lab2.b * lab2.b)
let cBar = (c1 + c2) / 2.0
let cBar7 = pow(cBar, 7)
let g = 0.5 * (1 - sqrt(cBar7 / (cBar7 + pow(25, 7))))
let a1p = (1 + g) * lab1.a
let a2p = (1 + g) * lab2.a
let c1p = sqrt(a1p * a1p + lab1.b * lab1.b)
let c2p = sqrt(a2p * a2p + lab2.b * lab2.b)
let h1p = atan2ToDegrees(lab1.b, a1p)
let h2p = atan2ToDegrees(lab2.b, a2p)
let deltaLp = lab2.l - lab1.l
let deltaCp = c2p - c1p
var deltaHp: Double = 0
if c1p * c2p == 0 {
deltaHp = 0
} else {
let diff = h2p - h1p
if abs(diff) <= 180 {
deltaHp = diff
} else if diff > 180 {
deltaHp = diff - 360
} else {
deltaHp = diff + 360
}
}
let deltaHp2 = 2 * sqrt(c1p * c2p) * sin(deltaHp * .pi / 360.0)
let lBarp = (lab1.l + lab2.l) / 2.0
let cBarp = (c1p + c2p) / 2.0
var hBarp: Double
if c1p * c2p == 0 {
hBarp = h1p + h2p
} else {
if abs(h1p - h2p) <= 180 {
hBarp = (h1p + h2p) / 2.0
} else if h1p + h2p < 360 {
hBarp = (h1p + h2p + 360) / 2.0
} else {
hBarp = (h1p + h2p - 360) / 2.0
}
}
let t = 1
- 0.17 * cos(deg2rad(hBarp - 30))
+ 0.24 * cos(deg2rad(2 * hBarp))
+ 0.32 * cos(deg2rad(3 * hBarp + 6))
- 0.20 * cos(deg2rad(4 * hBarp - 63))
let dTheta = 30 * exp(-pow((hBarp - 275) / 25, 2))
let cBarp7 = pow(cBarp, 7)
let rc = 2 * sqrt(cBarp7 / (cBarp7 + pow(25, 7)))
let sl = 1 + (0.015 * pow(lBarp - 50, 2)) / sqrt(20 + pow(lBarp - 50, 2))
let sc = 1 + 0.045 * cBarp
let sh = 1 + 0.015 * cBarp * t
let rt = -sin(deg2rad(2 * dTheta)) * rc
let lTerm = deltaLp / (kL * sl)
let cTerm = deltaCp / (kC * sc)
let hTerm = deltaHp2 / (kH * sh)
return sqrt(
lTerm * lTerm
+ cTerm * cTerm
+ hTerm * hTerm
+ rt * cTerm * hTerm
)
}
/// Classify a ΔE value against user thresholds.
public static func classify(deltaE: Double, goodMax: Double, warningMax: Double) -> SwatchClassification {
if deltaE < goodMax { return .good }
if deltaE < warningMax { return .warning }
return .bad
}
/// Evaluate a patch: compute intended/measured sRGB and ΔE if both Lab values are present.
public static func evaluate(
patch: ChartreadPatch,
goodMax: Double,
warningMax: Double
) -> SwatchEvaluation? {
guard let measured = resolveLab(patch.measured) else { return nil }
let measuredRGB = LabColorMath.labToSRGB(measured)
if let expectedColor = patch.expected,
let expectedLab = resolveLab(expectedColor) {
let de = deltaE00(expectedLab, measured)
let intendedRGB = LabColorMath.labToSRGB(expectedLab)
return SwatchEvaluation(
intended: intendedRGB,
measured: measuredRGB,
deltaE: de,
classification: classify(deltaE: de, goodMax: goodMax, warningMax: warningMax)
)
} else {
// No reference: still render measured colour, no ΔE.
return SwatchEvaluation(
intended: measuredRGB,
measured: measuredRGB,
deltaE: nil,
classification: .good
)
}
}
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
public static func resolveLab(_ color: PatchColor) -> LabColor? {
if let lab = color.lab {
return LabColor(l: lab.l, a: lab.a, b: lab.b)
}
guard let xyz = color.xyz else { return nil }
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
}
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
let radians = atan2(y, x)
var degrees = radians * 180.0 / .pi
if degrees < 0 { degrees += 360 }
return degrees
}
private static func deg2rad(_ degrees: Double) -> Double {
degrees * .pi / 180.0
}
}
@@ -0,0 +1,58 @@
import Foundation
/// A device discovered by the Argyll `instlist` fork.
public struct InstrumentDevice: Codable, Sendable, Equatable, Identifiable {
public let port: Int
public let name: String
public let type: String
public init(port: Int, name: String, type: String) {
self.port = port
self.name = name
self.type = type
}
public var id: Int { port }
/// XY tables are identified by name or type matching the fork pattern.
public var isXY: Bool {
let combined = "\(name) \(type)".lowercased()
let pattern = #"/spectro\s?scan|i1io/"#
return combined.range(of: pattern, options: .regularExpression) != nil
}
/// Human-readable label shown in the picker.
public var displayName: String {
let xyTag = isXY ? " · XY Table" : ""
return "\(name) [\(type)]\(xyTag)"
}
}
/// The users choice for a chartread session.
public enum InstrumentSelection: Sendable, Equatable {
/// Auto / first available port `chartread` omits `-c`.
case auto
/// A concrete instrument.
case device(InstrumentDevice)
/// The value to pass to `chartread -c`.
/// `nil` means omit `-c` (port 1 and Auto both map to no flag).
public var chartreadPort: Int? {
switch self {
case .auto:
return nil
case .device(let device):
return device.port == 1 ? nil : device.port
}
}
/// Whether the current selection implies an XY table workflow.
public var isXY: Bool {
switch self {
case .auto:
return false
case .device(let device):
return device.isXY
}
}
}
@@ -0,0 +1,72 @@
import Foundation
/// Errors from `instlist` output parsing.
public enum InstrumentParserError: Error, Sendable, Equatable {
case malformedJSON
case missingDevices
case invalidPort
}
/// Parses the Argyll `instlist` stdout document.
///
/// Fork `instlist` emits pretty-printed JSON with the shape
/// `{ "event": "instruments", "devices": [ { "port": 1, "name": "...", "type": "..." } ] }`.
/// If JSON decoding fails, a constrained regex fallback is used.
/// Only lines accepted by the fallback must also match known instrument tokens.
public enum InstrumentParser {
/// Known instrument tokens used by the regex fallback.
public static let knownInstrumentPattern =
#"i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein"#
/// Parse the complete `instlist` output.
public static func parse(_ output: String) throws -> [InstrumentDevice] {
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
if let data = trimmed.data(using: .utf8),
let decoded = try? decodeJSON(data) {
return decoded
}
let regex = try? NSRegularExpression(
pattern: #"^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?"#,
options: [.caseInsensitive, .anchorsMatchLines]
)
var devices: [InstrumentDevice] = []
let range = NSRange(trimmed.startIndex..., in: trimmed)
let matches = regex?.matches(in: trimmed, options: [], range: range) ?? []
for match in matches {
guard let portString = substring(trimmed, range: match.range(at: 1)),
let port = Int(portString), port > 0 else { continue }
let name = substring(trimmed, range: match.range(at: 2))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let type = substring(trimmed, range: match.range(at: 3))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let combined = "\(name) \(type)".lowercased()
guard combined.range(of: knownInstrumentPattern,
options: [.regularExpression, .caseInsensitive]) != nil,
!name.isEmpty else { continue }
devices.append(InstrumentDevice(port: port, name: name, type: type))
}
return devices
}
private static func decodeJSON(_ data: Data) throws -> [InstrumentDevice] {
let output = try JSONDecoder().decode(InstlistOutput.self, from: data)
return output.devices
}
private static func substring(_ source: String, range: NSRange) -> String? {
guard range.location != NSNotFound, let r = Range(range, in: source) else { return nil }
return String(source[r])
}
}
private struct InstlistOutput: Decodable {
let event: String
let devices: [InstrumentDevice]
}
@@ -0,0 +1,184 @@
import Foundation
/// XYZ tristimulus values, stored in the 0100 scale used by the Argyll fork.
public struct XYZColor: Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
public init(x: Double, y: Double, z: Double) {
self.x = x
self.y = y
self.z = z
}
}
/// CIELab value (D50).
public struct LabColor: Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
public init(l: Double, a: Double, b: Double) {
self.l = l
self.a = a
self.b = b
}
}
/// sRGB colour in 01 display space.
public struct DisplayRGB: Sendable, Equatable {
public let r: Double
public let g: Double
public let b: Double
public init(r: Double, g: Double, b: Double) {
self.r = r
self.g = g
self.b = b
}
public var clamped: DisplayRGB {
DisplayRGB(r: min(1, max(0, r)), g: min(1, max(0, g)), b: min(1, max(0, b)))
}
}
/// Colour-space conversions used by the swatch grid.
///
/// All numeric paths are deterministic and avoid platform colour-management APIs.
public enum LabColorMath {
// Reference white for D50 (0100 scale).
static let d50White = (X: 96.4212, Y: 100.0, Z: 82.5188)
// Reference white for D65 (0100 scale).
static let d65White = (X: 95.0489, Y: 100.0, Z: 108.8840)
// Bradford cone-response matrix and its inverse (XYZ -> LMS).
static let bradford = [
[ 0.8951, 0.2664, -0.1614],
[-0.7502, 1.7135, 0.0367],
[ 0.0389, -0.0685, 1.0296]
]
static let bradfordInv = [
[ 0.9869929, -0.1470543, 0.1599627],
[ 0.4323053, 0.5183603, 0.0492912],
[-0.0085287, 0.0400428, 0.9684866]
]
// sRGB D65 matrix (XYZ -> linear sRGB, using 0100 inputs).
static let srgbMatrix = [
[ 3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[ 0.0557, -0.2040, 1.0570]
]
/// Convert XYZ (0100) to CIELab D50.
public static func xyzToLab(_ xyz: XYZColor) -> LabColor {
let f: (Double) -> Double = { t in
let delta = 6.0 / 29.0
if t > delta * delta * delta {
return pow(t, 1.0 / 3.0)
} else {
return t / (3 * delta * delta) + 4.0 / 29.0
}
}
let x = f(xyz.x / d50White.X)
let y = f(xyz.y / d50White.Y)
let zr = f(xyz.z / d50White.Z)
return LabColor(
l: 116.0 * y - 16.0,
a: 500.0 * (x - y),
b: 200.0 * (y - zr)
)
}
/// Convert CIELab D50 to XYZ (0100).
public static func labToXYZ(_ lab: LabColor) -> XYZColor {
let finv: (Double) -> Double = { t in
let delta = 6.0 / 29.0
if t > delta {
return t * t * t
} else {
return 3 * delta * delta * (t - 4.0 / 29.0)
}
}
let yr = (lab.l + 16.0) / 116.0
let xr = yr + lab.a / 500.0
let zr = yr - lab.b / 200.0
return XYZColor(
x: finv(xr) * d50White.X,
y: finv(yr) * d50White.Y,
z: finv(zr) * d50White.Z
)
}
/// Convert XYZ D50 to XYZ D65 using the Bradford chromatic adaptation.
public static func adaptD50ToD65(_ xyz: XYZColor) -> XYZColor {
let source = matrixMultiply(bradford, [xyz.x, xyz.y, xyz.z])
let srcWhite = matrixMultiply(bradford, [d50White.X, d50White.Y, d50White.Z])
let dstWhite = matrixMultiply(bradford, [d65White.X, d65White.Y, d65White.Z])
let scaled = [
source[0] * (dstWhite[0] / srcWhite[0]),
source[1] * (dstWhite[1] / srcWhite[1]),
source[2] * (dstWhite[2] / srcWhite[2])
]
return XYZColor(
x: scaled[0] * bradfordInv[0][0] + scaled[1] * bradfordInv[0][1] + scaled[2] * bradfordInv[0][2],
y: scaled[0] * bradfordInv[1][0] + scaled[1] * bradfordInv[1][1] + scaled[2] * bradfordInv[1][2],
z: scaled[0] * bradfordInv[2][0] + scaled[1] * bradfordInv[2][1] + scaled[2] * bradfordInv[2][2]
)
}
/// Convert XYZ D65 (0100) to linear sRGB, apply gamma, and clamp.
public static func xyzToSRGB(_ xyz: XYZColor) -> DisplayRGB {
// sRGB matrix is defined for XYZ with D65 white at Y = 1.0.
// The input is 0100, so scale by 100 first.
let scaled = [xyz.x / 100.0, xyz.y / 100.0, xyz.z / 100.0]
let linear = matrixMultiply(srgbMatrix, scaled)
func gamma(_ c: Double) -> Double {
if c <= 0.0031308 {
return 12.92 * c
} else {
return 1.055 * pow(c, 1.0 / 2.4) - 0.055
}
}
return DisplayRGB(
r: gamma(linear[0]),
g: gamma(linear[1]),
b: gamma(linear[2])
).clamped
}
/// Complete D50 Lab -> display sRGB conversion.
public static func labToSRGB(_ lab: LabColor) -> DisplayRGB {
let xyz50 = labToXYZ(lab)
let xyz65 = adaptD50ToD65(xyz50)
return xyzToSRGB(xyz65)
}
/// Convert an Argyll XYZ array (0100) to Lab and then to sRGB.
public static func xyzArrayToSRGB(_ xyz: [Double]) -> DisplayRGB? {
guard xyz.count >= 3 else { return nil }
return labToSRGB(xyzToLab(XYZColor(x: xyz[0], y: xyz[1], z: xyz[2])))
}
private static func matrixMultiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
var result = [Double](repeating: 0, count: m.count)
for i in 0..<m.count {
for j in 0..<v.count {
result[i] += m[i][j] * v[j]
}
}
return result
}
}
@@ -0,0 +1,172 @@
import Foundation
/// Errors from pass-snapshot, promote, and discovery operations.
public enum MeasurementArtefactError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case canonicalMissing(URL)
case snapshotFailed(String)
case promoteFailed(String)
case noPassFiles
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid measurement basename: \(name)"
case .canonicalMissing(let url):
return "Canonical .ti3 not found: \(url.path)"
case .snapshotFailed(let reason):
return "Snapshot failed: \(reason)"
case .promoteFailed(let reason):
return "Promote failed: \(reason)"
case .noPassFiles:
return "No pass .ti3 snapshots are available."
}
}
}
/// Filesystem helpers for multi-pass measurement artefacts.
///
/// Pass snapshots use 1-based numbering and are tracked as
/// `<basename>_passN.ti3`. Canonical `<basename>.ti3` only appears after
/// Finish / Average (docs/24 #109, #110).
public enum MeasurementArtefacts {
/// Find all existing pass snapshots in `cwd`, sorted numerically.
public static func passSnapshots(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> [URL] {
guard let entries = try? fileManager.contentsOfDirectory(
at: cwd,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return [] }
let prefix = "\(basename)_pass"
let suffix = "ti3"
let passes: [(Int, URL)] = entries.compactMap { url in
let name = url.lastPathComponent
guard url.pathExtension.lowercased() == suffix,
name.hasPrefix(prefix)
else { return nil }
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
guard let number = Int(numberPart), number > 0 else { return nil }
return (number, url)
}
return passes
.sorted { $0.0 < $1.0 }
.map { $0.1 }
}
/// The next 1-based pass number.
public static func nextPassNumber(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> Int {
let existing = passSnapshots(basename: basename, cwd: cwd, fileManager: fileManager)
guard let last = existing.last else { return 1 }
let name = last.lastPathComponent
let prefix = "\(basename)_pass"
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
return (Int(numberPart) ?? 0) + 1
}
/// Snapshot canonical `<basename>.ti3` to `<basename>_passN.ti3` and remove canonical.
///
/// The copy is written to a temp sibling and atomically renamed before canonical is deleted.
public static func snapshotPass(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
guard fileManager.fileExists(atPath: canonical.path) else {
throw MeasurementArtefactError.canonicalMissing(canonical)
}
let passNumber = nextPassNumber(basename: cleanBasename, cwd: cwd, fileManager: fileManager)
let pass = cwd.appendingPathComponent("\(cleanBasename)_pass\(passNumber).ti3")
let temp = cwd.appendingPathComponent(".\(cleanBasename)_pass\(passNumber).ti3.iccery-snap.tmp")
if fileManager.fileExists(atPath: temp.path) {
try? fileManager.removeItem(at: temp)
}
do {
try fileManager.copyItem(at: canonical, to: temp)
} catch {
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
}
if fileManager.fileExists(atPath: pass.path) {
try? fileManager.removeItem(at: pass)
}
do {
try fileManager.moveItem(at: temp, to: pass)
} catch {
try? fileManager.removeItem(at: temp)
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
}
do {
try fileManager.removeItem(at: canonical)
} catch {
// The pass file is durable; canonical removal failure is logged but not fatal.
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
}
return pass
}
/// Promote a single pass snapshot to canonical `<basename>.ti3`.
public static func promotePass(
pass: URL,
basename: String,
cwd: URL,
fileManager: FileManager = .default
) throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
let temp = cwd.appendingPathComponent(".\(cleanBasename).ti3.iccery-promo.tmp")
guard fileManager.fileExists(atPath: pass.path) else {
throw MeasurementArtefactError.noPassFiles
}
if fileManager.fileExists(atPath: temp.path) {
try? fileManager.removeItem(at: temp)
}
do {
try fileManager.copyItem(at: pass, to: temp)
} catch {
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
}
if fileManager.fileExists(atPath: canonical.path) {
try? fileManager.removeItem(at: canonical)
}
do {
try fileManager.moveItem(at: temp, to: canonical)
} catch {
try? fileManager.removeItem(at: temp)
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
}
return canonical
}
/// Canonical `<basename>.ti3` URL, regardless of existence.
public static func canonicalURL(basename: String, cwd: URL) throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
return cwd.appendingPathComponent("\(cleanBasename).ti3")
}
}
@@ -35,6 +35,18 @@ public struct ProcessLineDecoder: Sendable {
return rest.isEmpty ? nil : Self.decode(rest)
}
/// Emits the current unterminated tail as a single line and clears it.
/// Used by `ProcessManager.flushPartialLine` for tools that emit
/// progress dots without newlines.
public mutating func flushPartial() -> String? {
guard !pending.isEmpty else { return nil }
var rest = pending
pending.removeAll(keepingCapacity: false)
if rest.last == 0x0D { rest = rest.dropLast() }
let text = Self.decode(rest)
return text.isEmpty ? nil : text
}
private static func decode(_ bytes: Data.SubSequence) -> String {
String(decoding: bytes, as: UTF8.self)
}
@@ -20,8 +20,11 @@ public struct CapturedResult: Sendable, Equatable {
/// with the prefix stripped; all other stdout is `stdout` events.
/// - `exit` is emitted exactly once per child, and only after both
/// output pipes reach EOF so no buffered output is lost on fast
/// exits or kills.
/// - `kill` drops the stdin handle so writers fail fast.
/// exits or kills. If EOFs never arrive, a watchdog finalizes.
/// - `kill` runs a pre-kill hook (e.g. XY `q\n` + 500 ms park) before
/// terminating. Hooks are removed once the child finalizes.
/// - `killAll` on `NSApplication.willTerminate` and last-window close
/// runs all hooks and terminates every child (#147, #149).
public actor ProcessManager {
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
@@ -92,12 +95,18 @@ public actor ProcessManager {
/// pipes have also reached EOF.
var pendingExitCode: Int32?
var finalized = false
/// Watchdog that forces finalization if EOFs never arrive.
var finalizeTask: Task<Void, Never>?
}
private var children: [String: RunningChild] = [:]
/// Processes owned by `runCaptured` (dup detection + kill support).
private var captured: [String: Process] = [:]
/// Hooks run by `kill` before terminating the child.
/// Used by `chartread` to park an XY head with `q\n`.
private var preKillHooks: [String: @Sendable () async -> Void] = [:]
/// Ids of currently-running children.
public var runningIDs: [String] { Array(children.keys) + captured.keys }
@@ -105,6 +114,14 @@ public actor ProcessManager {
children[id] != nil || captured[id] != nil
}
// MARK: - Pre-kill hooks
/// Register a hook to run before `kill(id:)` terminates the child.
/// The hook is removed once the child finalizes.
public func setPreKillHook(id: String, hook: @escaping @Sendable () async -> Void) {
preKillHooks[id] = hook
}
// MARK: - Spawn (streaming)
/// Spawns a streaming child. Returns after spawn; callers wait for
@@ -142,14 +159,6 @@ public actor ProcessManager {
stderrDecoder: ProcessLineDecoder()
)
do {
try process.run()
} catch {
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in
@@ -167,6 +176,15 @@ public actor ProcessManager {
guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
}
do {
try process.run()
} catch {
preKillHooks.removeValue(forKey: id)
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
}
// MARK: - Spawn (captured)
@@ -198,17 +216,77 @@ public actor ProcessManager {
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
// Register before run() so a concurrent duplicate spawn fails.
// Register and set up the termination hand-off before run() so
// a very fast exit is never missed (#50, #52).
captured[id] = process
let capturedProcess = process
// Box is local and synchronised with an NSLock; the @unchecked
// Sendable annotation is safe because all access is under the lock.
final class Box: @unchecked Sendable {
private let lock = NSLock()
private var status: Int32?
private var continuation: CheckedContinuation<Int32, Never>?
/// Try to resume an already-stored continuation with the exit
/// status. Returns true if a continuation was resumed.
func resume(with status: Int32) -> Bool {
lock.lock()
if let cont = continuation {
continuation = nil
lock.unlock()
cont.resume(returning: status)
return true
}
self.status = status
lock.unlock()
return false
}
/// Store a continuation, returning any status that arrived
/// before it. The caller must resume with the returned status.
func store(_ continuation: CheckedContinuation<Int32, Never>) -> Int32? {
lock.lock()
if let status = status {
self.status = nil
self.continuation = nil
lock.unlock()
return status
}
self.continuation = continuation
// A fast exit may have raced past the first nil-check.
if let status = status {
self.status = nil
self.continuation = nil
lock.unlock()
return status
}
lock.unlock()
return nil
}
}
let box = Box()
capturedProcess.terminationHandler = { proc in
_ = box.resume(with: proc.terminationStatus)
}
do {
try process.run()
} catch {
_ = box.resume(with: -1)
captured.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
// Close the parent write ends so readDataToEndOfFile() gets EOF
// as soon as the child exits; the child still has its own copies.
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
return await withTaskCancellationHandler {
async let outData = Task.detached {
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}.value
@@ -217,22 +295,35 @@ public actor ProcessManager {
}.value
let code = await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus)
if let status = box.store(continuation) {
continuation.resume(returning: status)
}
}
let (out, err) = await (outData, errData)
// If kill() already reaped this child, its exit event went out.
if captured.removeValue(forKey: id) != nil {
// Emit the real exit code once, regardless of whether kill()
// already removed the id from `captured`.
_ = captured.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
emit(.exit(id: id, code: code))
}
return CapturedResult(
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: code
)
} onCancel: { [weak self] in
// If the awaiting Task is cancelled, terminate the child so
// callers like runApplycal never replace a good profile with
// a truncated tmp.
if capturedProcess.isRunning {
capturedProcess.terminate()
}
Task { [weak self] in
await self?.kill(id: id)
}
}
}
// MARK: - stdin
@@ -255,39 +346,89 @@ public actor ProcessManager {
try sendStdin(id: id, bytes: Data(text.utf8))
}
// MARK: - Partial-line flush
/// Emits the current unterminated tail of a streaming child's stdout
/// and stderr as ordinary lines. Callers (e.g. `colprof`) use this
/// to flush progress dots without waiting for a newline.
public func flushPartialLine(id: String) {
guard var child = children[id], !child.finalized else { return }
if let tail = child.stdoutDecoder.flushPartial() {
if tail.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: tail))
}
}
if let tail = child.stderrDecoder.flushPartial() {
emit(.stderr(id: id, line: tail))
}
children[id] = child
}
// MARK: - Kill
/// Terminates a child. The `exit` event still fires exactly once.
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
public func kill(id: String) {
/// Terminates a child. First runs any registered pre-kill hook, then
/// drops stdin and signals the process. For streaming children the
/// `exit` event is emitted once both stdout and stderr EOFs have been
/// seen (or the watchdog finalizes). For captured children the real
/// exit code is emitted by `runCaptured` itself.
public func kill(id: String) async {
if let hook = preKillHooks.removeValue(forKey: id) {
await hook()
}
if var child = children[id] {
try? child.stdin?.close()
child.stdin = nil
children[id] = child
if child.process.isRunning {
child.process.terminate()
} else {
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
} else if child.pendingExitCode == nil {
// The process already exited but `didTerminate` has not
// run; synthesize it so `maybeFinalize` can fire.
didTerminate(id: id, code: child.process.terminationStatus)
}
return
}
if let process = captured[id] {
if process.isRunning { process.terminate() }
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: process.terminationStatus))
}
// Do not emit `.exit` here; `runCaptured` emits the real code
// after the process reaps.
return
}
}
/// Terminates every running child; returns how many were signaled
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
@discardableResult
public func killAll() -> Int {
let ids = Array(children.keys) + Array(captured.keys)
for id in ids { kill(id: id) }
public func killAll() async -> Int {
let ids = runningIDs
for id in ids { await kill(id: id) }
return ids.count
}
// MARK: - Force kill (SIGKILL fallback)
/// Sends `SIGKILL` to a streaming child if it is still running.
/// Used by the finalization watchdog when a graceful `terminate()`
/// does not cause the process to exit.
public func forceKill(id: String) {
guard let child = children[id],
!child.finalized,
child.process.isRunning
else { return }
let pid = child.process.processIdentifier
guard pid > 0 else { return }
_ = Darwin.kill(pid, SIGKILL)
}
// MARK: - Internals
private func childEnvironment(extra: [String: String]) -> [String: String] {
@@ -339,6 +480,16 @@ public actor ProcessManager {
child.pendingExitCode = code
try? child.stdin?.close()
child.stdin = nil
// Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard let self else { return }
await self.forceKill(id: id)
await self.forceFinalize(id: id)
}
children[id] = child
maybeFinalize(id: id)
}
@@ -351,8 +502,12 @@ public actor ProcessManager {
child.stdoutEOF, child.stderrEOF,
!child.finalized
else { return }
child.finalized = true
child.finalizeTask?.cancel()
child.finalizeTask = nil
children.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
// Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder),
@@ -369,4 +524,20 @@ public actor ProcessManager {
}
emit(.exit(id: id, code: code))
}
/// Forces finalization even when one or both EOFs are missing.
/// Used by the `didTerminate` watchdog.
private func forceFinalize(id: String) {
guard var child = children[id], !child.finalized else { return }
if child.pendingExitCode == nil {
child.pendingExitCode = -9
}
child.stdoutEOF = true
child.stderrEOF = true
child.finalizeTask?.cancel()
child.finalizeTask = nil
children[id] = child
maybeFinalize(id: id)
}
}
@@ -0,0 +1,50 @@
import Foundation
/// Errors during `applycal` argv construction.
public enum ApplycalArgError: LocalizedError, Equatable, Sendable {
case invalidCalibrationPath
case invalidInputProfileURL
public var errorDescription: String? {
switch self {
case .invalidCalibrationPath:
return "Calibration path is invalid or empty"
case .invalidInputProfileURL:
return "Input profile path is invalid or empty"
}
}
}
/// Pure argv builder for Argyll's `applycal` tool.
///
/// `applycal` is always run captured, never streamed.
public enum ApplycalArgs {
/// Builds `applycal -v -a {cal} {input} [{output}]`.
///
/// `-u` (unapply) is rejected at the builder level the UI never
/// sends it (docs/04 §7.2).
public static func build(config: ApplycalConfig) throws -> [String] {
let cal = config.calibrationPath.trimmingCharacters(in: .whitespaces)
guard !cal.isEmpty else { throw ApplycalArgError.invalidCalibrationPath }
let input = config.inputProfileURL.path
guard !input.isEmpty else { throw ApplycalArgError.invalidInputProfileURL }
var args: [String] = ["-v"]
if config.unapply {
// Defensive: should never be called from the UI.
args.append("-u")
} else {
args.append("-a")
}
args.append(contentsOf: [cal, input])
if let output = config.outputProfileURL?.path, !output.isEmpty {
args.append(output)
}
return args
}
}
@@ -0,0 +1,22 @@
import Foundation
/// Configuration for an Argyll `applycal` run.
public struct ApplycalConfig: Sendable, Equatable {
public var calibrationPath: String
public var inputProfileURL: URL
public var outputProfileURL: URL?
public var unapply: Bool
/// In-place when `outputProfileURL` is `nil`.
public init(
calibrationPath: String,
inputProfileURL: URL,
outputProfileURL: URL? = nil,
unapply: Bool = false
) {
self.calibrationPath = calibrationPath
self.inputProfileURL = inputProfileURL
self.outputProfileURL = outputProfileURL
self.unapply = unapply
}
}
@@ -0,0 +1,79 @@
import Foundation
/// Errors during `colprof` argv construction.
public enum ColprofArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid colprof basename: \(name)"
}
}
}
/// Pure argv builder for Argyll's `colprof` tool.
public enum ColprofArgs {
/// Builds `colprof` argv per the Gronod fork protocol.
///
/// Always `-v -a {algorithm} -q {quality}`. Optional flags are added
/// only when their fields are non-empty and meaningful. `-f` has
/// special handling for "none" (omit), "" (bare flag), and a custom
/// `.sp` path (passed through). `-c`/`-d` viewing conditions are
/// skipped when set to "none".
public static func build(config: ColprofConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v"]
args.append(contentsOf: ["-a", config.algorithm])
args.append(contentsOf: ["-q", config.quality])
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
args.append(contentsOf: ["-t", intent])
}
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
switch fwa.lowercased() {
case "none", "":
// "none" omits the flag; an explicit empty string means bare -f.
if fwa.isEmpty {
args.append("-f")
}
default:
args.append(contentsOf: ["-f", fwa])
}
}
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
args.append(contentsOf: ["-i", illuminant])
}
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
args.append(contentsOf: ["-o", observer])
}
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
!inputCond.isEmpty, inputCond.lowercased() != "none" {
args.append(contentsOf: ["-c", inputCond])
}
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
!outputCond.isEmpty, outputCond.lowercased() != "none" {
args.append(contentsOf: ["-d", outputCond])
}
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
if let description = profileDescription, !description.isEmpty {
args.append(contentsOf: ["-D", description])
}
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
args.append(contentsOf: ["-C", copyright])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,45 @@
import Foundation
/// Configuration for an Argyll `colprof` run (issue #23, docs/16).
public struct ColprofConfig: Sendable, Equatable {
public var algorithm: String
public var quality: String
public var intent: String?
public var fwa: String?
public var illuminant: String?
public var observer: String?
public var inputViewingCond: String?
public var outputViewingCond: String?
public var description: String?
public var copyright: String?
public var basename: String
public var workingDirectory: URL?
public init(
algorithm: String = "l",
quality: String = "m",
intent: String? = nil,
fwa: String? = nil,
illuminant: String? = nil,
observer: String? = nil,
inputViewingCond: String? = nil,
outputViewingCond: String? = nil,
description: String? = nil,
copyright: String? = nil,
basename: String,
workingDirectory: URL? = nil
) {
self.algorithm = algorithm
self.quality = quality
self.intent = intent
self.fwa = fwa
self.illuminant = illuminant
self.observer = observer
self.inputViewingCond = inputViewingCond
self.outputViewingCond = outputViewingCond
self.description = description
self.copyright = copyright
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Classified `colprof` stdout progress milestone.
public enum ColprofProgress: Sendable, Equatable {
case gamutMapping
case fittingClut
case writingIcc
case unknown
}
/// Parses `colprof` plaintext progress (docs/16 §6.3).
///
/// The Gronod fork supports `-u` JSON, but ICCery v2.0 does not pass it.
/// Progress is therefore inferred from case-insensitive substring matches.
public enum ColprofProgressClassifier {
public static func classify(line: String) -> ColprofProgress {
let lower = line.lowercased()
if lower.contains("gamut mapping") {
return .gamutMapping
}
if lower.contains("fitting") || lower.contains("clut") {
return .fittingClut
}
if lower.contains("writing") || lower.contains("icc profile") {
return .writingIcc
}
return .unknown
}
}
@@ -0,0 +1,49 @@
import Foundation
/// Computes a consecutive-breach warning from verification history.
///
/// A drift alert triggers when the most recent chronologically consecutive
/// poor records form a run of at least two, and the first and last of that
/// run are on distinct UTC days or at least one hour apart.
public enum DriftAlert {
/// Returns an alert message, or `nil` when no consecutive breach exists.
public static func compute(from records: [VerificationRecord]) -> String? {
// Work in chronological order.
let chronological = records.sorted { $0.timestamp < $1.timestamp }
// Build the longest suffix of consecutive `.poor` records.
// Non-poor records break the run, so we stop at the first non-poor
// encountered from the end.
var run: [VerificationRecord] = []
for record in chronological.reversed() {
if record.status == .poor {
run.insert(record, at: 0)
} else {
break
}
}
guard run.count >= 2 else { return nil }
let first = run.first!
let last = run.last!
let sameDay = Calendar.utc.isDate(first.timestamp, inSameDayAs: last.timestamp)
let oneHour = last.timestamp.timeIntervalSince(first.timestamp) >= 3600
if !sameDay || oneHour {
return "Drift alert: poor results between \(first.id) and \(last.id)."
}
return nil
}
}
private extension Calendar {
static let utc: Calendar = {
var c = Calendar(identifier: .iso8601)
c.timeZone = TimeZone(identifier: "UTC")!
return c
}()
}
@@ -0,0 +1,29 @@
import Foundation
/// Errors during `iccgamut` argv construction.
public enum IccgamutArgError: LocalizedError, Equatable, Sendable {
case invalidProfileURL
public var errorDescription: String? {
switch self {
case .invalidProfileURL:
return "iccgamut requires a valid profile path"
}
}
}
/// Pure argv builder for Argyll's `iccgamut` tool.
public enum IccgamutArgs {
/// Builds `iccgamut -v -d {density} {profilePath}`.
///
/// The caller is responsible for ensuring `density` is a positive
/// integer. `-d` here is surface **density**, not a directory.
public static func build(config: IccgamutConfig) throws -> [String] {
let path = config.profileURL.path
guard !path.isEmpty else { throw IccgamutArgError.invalidProfileURL }
let density = max(1, config.density)
return ["-v", "-d", "\(density)", path]
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for an Argyll `iccgamut` run.
public struct IccgamutConfig: Sendable, Equatable {
public var profileURL: URL
public var density: Int
public init(profileURL: URL, density: Int = 10) {
self.profileURL = profileURL
self.density = density
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for a profile installation.
public struct InstallProfileConfig: Sendable, Equatable {
public var sourceURL: URL
public var options: InstallProfileOptions
public init(sourceURL: URL, options: InstallProfileOptions = InstallProfileOptions()) {
self.sourceURL = sourceURL
self.options = options
}
}
@@ -0,0 +1,31 @@
import Foundation
/// Collision policy for profile installation.
public enum ProfileCollisionPolicy: String, Sendable, Equatable, Codable, CaseIterable {
case overwrite
case rename
case cancel
}
/// Options for installing a finished profile into the OS colour store.
public struct InstallProfileOptions: Sendable, Equatable, Codable {
public var forceOverwrite: Bool
public var preferSystem: Bool
public var collisionPolicy: ProfileCollisionPolicy
public var openColorPanel: Bool
public var calibrationNote: String?
public init(
forceOverwrite: Bool = false,
preferSystem: Bool = false,
collisionPolicy: ProfileCollisionPolicy = .cancel,
openColorPanel: Bool = false,
calibrationNote: String? = nil
) {
self.forceOverwrite = forceOverwrite
self.preferSystem = preferSystem
self.collisionPolicy = collisionPolicy
self.openColorPanel = openColorPanel
self.calibrationNote = calibrationNote
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Result of installing a profile into the OS colour store.
public struct InstallProfileResult: Sendable, Equatable, Codable {
public var destPath: String
public var registered: Bool
public var overwritten: Bool
public var renamed: Bool
public var openedPanel: Bool
public var message: String
public var calibrationNote: String?
public init(
destPath: String,
registered: Bool,
overwritten: Bool,
renamed: Bool,
openedPanel: Bool,
message: String,
calibrationNote: String? = nil
) {
self.destPath = destPath
self.registered = registered
self.overwritten = overwritten
self.renamed = renamed
self.openedPanel = openedPanel
self.message = message
self.calibrationNote = calibrationNote
}
}
@@ -0,0 +1,37 @@
import Foundation
/// Errors during `profcheck` argv construction.
public enum ProfcheckArgError: LocalizedError, Equatable, Sendable {
case missingTi3
case missingIcc
case invalidTi3Path
public var errorDescription: String? {
switch self {
case .missingTi3:
return "profcheck requires a .ti3 file"
case .missingIcc:
return "profcheck requires a profile (.icc/.icm)"
case .invalidTi3Path:
return "profcheck .ti3 path is invalid"
}
}
}
/// Pure argv builder for Argyll's `profcheck` tool.
public enum ProfcheckArgs {
/// Builds `profcheck -v -k -s -u {ti3Path} {iccPath}`.
///
/// The `-u` here is the Gronod fork JSON report flag, not the
/// generic `-u` auto-fix that some Argyll builds use.
public static func build(config: ProfcheckConfig) throws -> [String] {
let ti3Path = config.ti3URL.path
let iccPath = config.iccURL.path
guard !ti3Path.isEmpty else { throw ProfcheckArgError.missingTi3 }
guard !iccPath.isEmpty else { throw ProfcheckArgError.missingIcc }
return ["-v", "-k", "-s", "-u", ti3Path, iccPath]
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for an Argyll `profcheck` run.
public struct ProfcheckConfig: Sendable, Equatable {
public var ti3URL: URL
public var iccURL: URL
public init(ti3URL: URL, iccURL: URL) {
self.ti3URL = ti3URL
self.iccURL = iccURL
}
}
@@ -0,0 +1,217 @@
import Foundation
/// Errors returned when `profcheck` output cannot be parsed.
public enum ProfcheckParserError: LocalizedError, Equatable, Sendable {
case unparseable
case jsonDecodingFailed
public var errorDescription: String? {
switch self {
case .unparseable:
return "Could not parse profcheck report"
case .jsonDecodingFailed:
return "profcheck JSON report could not be decoded"
}
}
}
/// Parses the mixed JSON/text output from `profcheck -v -k -s -u`.
public enum ProfcheckParser {
/// Parsing order (issue #25):
/// 1. Patch count from `No of test patches = N`.
/// 2. JSON objects; prefer one with `event == "report"` or `*_de2000` keys.
/// 3. Legacy text: `Profile check complete, errors(CIEDE2000): max. = X, avg. = Y, RMS = Z`.
/// 4. Broad regex fallback.
/// 5. If no metrics found, return a report whose `warning` is set.
public static func parse(_ output: String) -> ProfcheckReport {
var report = ProfcheckReport()
// 1. Patch count.
let patchRegex = try? NSRegularExpression(
pattern: #"No of test patches\s*=\s*(\d+)"#,
options: [.caseInsensitive]
)
if let match = patchRegex?.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
), let range = Range(match.range(at: 1), in: output) {
let count = Int(output[range])
report.patchCount = count
}
// 2. JSON objects.
let jsonObjects = extractJSONObjects(from: output)
for object in jsonObjects {
if let event = object["event"] as? String, event == "report" {
if let parsed = metrics(from: object) {
apply(metrics: parsed, to: &report)
return report
}
}
if hasMetricKeys(object) {
if let parsed = metrics(from: object) {
apply(metrics: parsed, to: &report)
return report
}
}
}
// 3. Legacy text.
let textRegex = try? NSRegularExpression(
pattern: #"Profile check complete, errors\(CIEDE2000\): max\.\s*=\s*([0-9.]+),\s*avg\.\s*=\s*([0-9.]+),\s*RMS\s*=\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
if let match = textRegex?.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
) {
let numbers = (1...3).compactMap { i -> Double? in
guard let range = Range(match.range(at: i), in: output) else { return nil }
return Double(output[range])
}
if numbers.count == 3 {
report.maxDE = numbers[0]
report.avgDE = numbers[1]
report.rmsDE = numbers[2]
report.status = report.avgDE.map { VerificationStatus.from(avgDE: $0) }
return report
}
}
// 4. Broad regex fallback.
if let fallback = parseRegexFallback(output) {
var merged = fallback
merged.patchCount = report.patchCount
return merged
}
// 5. Unparseable.
report.warning = "profcheck output did not contain a recognisable report."
return report
}
// MARK: - JSON extraction
private static func extractJSONObjects(from output: String) -> [[String: Any]] {
var objects: [[String: Any]] = []
var start: String.Index?
var depth = 0
for index in output.indices {
let char = output[index]
if char == "{" {
if depth == 0 {
start = index
}
depth += 1
} else if char == "}" {
if depth > 0 {
depth -= 1
if depth == 0, let start = start {
let jsonString = String(output[start...index])
if let data = jsonString.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
objects.append(object)
}
}
}
}
}
return objects
}
private static func hasMetricKeys(_ object: [String: Any]) -> Bool {
let keys = [
"avg_de", "avg_de2000",
"peak_de", "peak_de2000", "max_de",
"rms", "rms_de"
]
return keys.contains { object[$0] != nil }
}
private struct Metrics {
var avg: Double?
var max: Double?
var rms: Double?
}
private static func metrics(from object: [String: Any]) -> Metrics? {
var m = Metrics()
m.avg = doubleValue(for: "avg_de2000", in: object)
?? doubleValue(for: "avg_de", in: object)
m.max = doubleValue(for: "peak_de2000", in: object)
?? doubleValue(for: "peak_de", in: object)
?? doubleValue(for: "max_de", in: object)
?? doubleValue(for: "max_de2000", in: object)
m.rms = doubleValue(for: "rms", in: object)
?? doubleValue(for: "rms_de", in: object)
?? doubleValue(for: "rms_de2000", in: object)
guard m.avg != nil || m.max != nil || m.rms != nil else { return nil }
return m
}
private static func doubleValue(for key: String, in object: [String: Any]) -> Double? {
if let number = object[key] as? Double { return number }
if let number = object[key] as? NSNumber { return number.doubleValue }
if let string = object[key] as? String { return Double(string) }
return nil
}
private static func apply(metrics: Metrics, to report: inout ProfcheckReport) {
report.avgDE = metrics.avg
report.maxDE = metrics.max
report.rmsDE = metrics.rms
if let avg = metrics.avg {
report.status = VerificationStatus.from(avgDE: avg)
}
}
// MARK: - Regex fallback
private static func parseRegexFallback(_ output: String) -> ProfcheckReport? {
var report = ProfcheckReport()
let avgRegex = try? NSRegularExpression(
pattern: #"(?:avg\.?|average)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
let maxRegex = try? NSRegularExpression(
pattern: #"(?:max\.?|peak|maximum)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
let rmsRegex = try? NSRegularExpression(
pattern: #"(?:rms)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
report.avgDE = firstDouble(from: output, regex: avgRegex)
report.maxDE = firstDouble(from: output, regex: maxRegex)
report.rmsDE = firstDouble(from: output, regex: rmsRegex)
guard report.avgDE != nil || report.maxDE != nil || report.rmsDE != nil else {
return nil
}
if let avg = report.avgDE {
report.status = VerificationStatus.from(avgDE: avg)
}
return report
}
private static func firstDouble(from output: String, regex: NSRegularExpression?) -> Double? {
guard let regex = regex,
let match = regex.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
),
let range = Range(match.range(at: 1), in: output) else { return nil }
return Double(output[range])
}
}
@@ -0,0 +1,31 @@
import Foundation
/// Parsed result from a `profcheck -u` run.
public struct ProfcheckReport: Sendable, Equatable, Codable {
public var patchCount: Int?
public var avgDE: Double?
public var maxDE: Double?
public var rmsDE: Double?
public var status: VerificationStatus?
public var warning: String?
public var isValid: Bool {
avgDE != nil && maxDE != nil && rmsDE != nil
}
public init(
patchCount: Int? = nil,
avgDE: Double? = nil,
maxDE: Double? = nil,
rmsDE: Double? = nil,
status: VerificationStatus? = nil,
warning: String? = nil
) {
self.patchCount = patchCount
self.avgDE = avgDE
self.maxDE = maxDE
self.rmsDE = rmsDE
self.status = status
self.warning = warning
}
}
@@ -0,0 +1,238 @@
import Foundation
/// Errors thrown by `ProfileInstaller`.
public enum ProfileInstallError: LocalizedError, Equatable, Sendable {
case unsafeStem(String)
case sourceMissing
case sourceNotProfile
case sourceTooSmall
case systemRequiresAdminRights
case copyFailed(String)
case cancelled
public var errorDescription: String? {
switch self {
case .unsafeStem(let stem):
return "Profile name contains unsafe characters: \(stem)"
case .sourceMissing:
return "Source profile does not exist"
case .sourceNotProfile:
return "Source must be a .icc or .icm file"
case .sourceTooSmall:
return "Source file is too small to be a valid profile"
case .systemRequiresAdminRights:
return "Installing to /Library/ColorSync/Profiles requires administrator rights"
case .copyFailed(let reason):
return "Could not install profile: \(reason)"
case .cancelled:
return "Install cancelled"
}
}
}
/// Installs an ICC/ICM profile into the OS colour store.
public enum ProfileInstaller {
/// Resolves the destination URL that `install` would write to for the
/// given source and options, without copying anything. Useful for
/// collision previews in the UI.
public static func resolveDestinationURL(
for config: InstallProfileConfig,
fileManager: FileManager = .default
) throws -> URL {
let sourceURL = config.sourceURL
let ext = sourceURL.pathExtension.lowercased()
guard ext == "icc" || ext == "icm" else {
throw ProfileInstallError.sourceNotProfile
}
try validateSourceURL(sourceURL)
let destDir = destinationDirectory(for: config.options, fileManager: fileManager)
return destDir.appendingPathComponent(sourceURL.lastPathComponent)
}
/// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or
/// `/Library/ColorSync/Profiles`. Always copies, never moves.
public static func install(
config: InstallProfileConfig,
fileManager: FileManager = .default
) throws -> InstallProfileResult {
let fm = fileManager
// Source validation.
let sourceURL = config.sourceURL
guard fm.fileExists(atPath: sourceURL.path) else {
throw ProfileInstallError.sourceMissing
}
let ext = sourceURL.pathExtension.lowercased()
guard ext == "icc" || ext == "icm" else {
throw ProfileInstallError.sourceNotProfile
}
let attrs = try? fm.attributesOfItem(atPath: sourceURL.path)
let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else {
throw ProfileInstallError.sourceTooSmall
}
try validateSourceURL(sourceURL)
// Destination directory.
let destURL = try resolveDestinationURL(for: config, fileManager: fm)
try? fm.createDirectory(
at: destURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
// Collision resolution.
let destExists = fm.fileExists(atPath: destURL.path)
if destExists {
if config.options.forceOverwrite {
return try performInstall(
from: sourceURL,
to: destURL,
options: config.options,
fileManager: fm,
overwritten: true,
renamed: false
)
} else if config.options.collisionPolicy == .rename {
let epoch = Int(Date().timeIntervalSince1970)
let stem = sourceURL.deletingPathExtension().lastPathComponent
let renamedURL = destURL.deletingLastPathComponent()
.appendingPathComponent("\(stem)-\(epoch).\(ext)")
return try performInstall(
from: sourceURL,
to: renamedURL,
options: config.options,
fileManager: fm,
overwritten: false,
renamed: true
)
} else if config.options.collisionPolicy == .cancel {
throw ProfileInstallError.cancelled
} else {
// Default with askBeforeOverwrite the app must decide.
throw ProfileInstallError.copyFailed("destination already exists")
}
}
return try performInstall(
from: sourceURL,
to: destURL,
options: config.options,
fileManager: fm,
overwritten: false,
renamed: false
)
}
// MARK: - Private helpers
private static func validateSourceURL(_ sourceURL: URL) throws {
let path = sourceURL.path
let stem = sourceURL.deletingPathExtension().lastPathComponent
// Reject backslashes anywhere in the path.
guard !path.contains("\\") else {
throw ProfileInstallError.unsafeStem(stem)
}
// Reject any path component that is literally "." or "..".
// This allows names like "foo..bar" while blocking real traversal.
for component in sourceURL.pathComponents {
if component == "." || component == ".." {
throw ProfileInstallError.unsafeStem(stem)
}
}
}
private static func destinationDirectory(
for options: InstallProfileOptions,
fileManager: FileManager
) -> URL {
if options.preferSystem {
return URL(fileURLWithPath: "/Library/ColorSync/Profiles")
} else {
return fileManager.homeDirectoryForCurrentUser
.appendingPathComponent("Library/ColorSync/Profiles")
}
}
private static func performInstall(
from sourceURL: URL,
to destURL: URL,
options: InstallProfileOptions,
fileManager: FileManager,
overwritten: Bool,
renamed: Bool
) throws -> InstallProfileResult {
let fm = fileManager
let tmpURL = destURL.appendingPathExtension("iccery-install.tmp")
// Remove stale tmp.
try? fm.removeItem(at: tmpURL)
do {
try fm.copyItem(at: sourceURL, to: tmpURL)
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
let tmpSize = attrs?[.size] as? UInt64 ?? 0
guard tmpSize >= 128 else {
try? fm.removeItem(at: tmpURL)
throw ProfileInstallError.sourceTooSmall
}
if fm.fileExists(atPath: destURL.path) {
_ = try fm.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
try fm.moveItem(at: tmpURL, to: destURL)
}
} catch {
try? fm.removeItem(at: tmpURL)
// Surface a clear admin-rights hint when writing to system.
if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) {
throw ProfileInstallError.systemRequiresAdminRights
}
if let installError = error as? ProfileInstallError {
throw installError
}
throw ProfileInstallError.copyFailed(error.localizedDescription)
}
let registered = fm.fileExists(atPath: destURL.path)
var openedPanel = false
if options.openColorPanel {
openedPanel = openColorSyncUtility()
}
return InstallProfileResult(
destPath: destURL.path,
registered: registered,
overwritten: overwritten,
renamed: renamed,
openedPanel: openedPanel,
message: "Profile installed to \(destURL.path)",
calibrationNote: options.calibrationNote
)
}
private static func openColorSyncUtility() -> Bool {
let task = Process()
task.launchPath = "/usr/bin/open"
task.arguments = ["-a", "ColorSync Utility"]
task.environment = ["ARGYLL_NOT_INTERACTIVE": "1"]
do {
try task.run()
task.waitUntilExit()
} catch {
return false
}
return true
}
}
@@ -0,0 +1,122 @@
import Foundation
/// Persistence for `VerificationRecord` entries.
public actor VerificationHistoryStore {
/// Default cap.
public static let defaultCapacity = 1000
/// Path to the JSON store.
public let url: URL
/// In-memory cache, kept in sync with disk.
private var records: [VerificationRecord] = []
private let capacity: Int
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
capacity: Int = defaultCapacity
) {
self.url = url
self.capacity = capacity
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .iso8601
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
}
/// Loads records from disk. Returns the existing cache if already loaded.
///
/// Throws when the file exists but cannot be parsed; the existing file
/// is never overwritten in that case.
public func load() throws -> [VerificationRecord] {
guard records.isEmpty else { return records }
let fm = FileManager.default
guard fm.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url) else { return [] }
records = try decoder.decode([VerificationRecord].self, from: data)
return records
}
/// Returns all records.
public func all() -> [VerificationRecord] {
records
}
/// Records matching the optional printer filter.
public func filtered(by printer: String?) -> [VerificationRecord] {
guard let printer = printer, !printer.isEmpty else { return records }
return records.filter { $0.printerName == printer }
}
/// Appends a record, trims to capacity, and writes atomically.
///
/// Loads the existing history first and propagates any load error so an
/// unparseable file is never overwritten.
@discardableResult
public func append(_ record: VerificationRecord) throws -> [VerificationRecord] {
try load()
var updated = records
updated.append(record)
if updated.count > capacity {
updated.sort { $0.timestamp < $1.timestamp }
updated = Array(updated.suffix(capacity))
}
try write(updated)
records = updated
return updated
}
/// Removes all history and updates disk.
public func clear() throws {
try write([])
records = []
}
/// RFC-4180 CSV export.
public func exportCSV() -> String {
var lines: [String] = [
csvRow(["id", "profile_name", "printer_name", "avg_de", "max_de", "rms_de", "patch_count", "status", "timestamp"])
]
for record in records {
lines.append(csvRow([
record.id,
record.profileName,
record.printerName,
String(record.avgDE),
String(record.maxDE),
String(record.rmsDE),
String(record.patchCount),
record.status.rawValue,
ISO8601DateFormatter().string(from: record.timestamp)
]))
}
return lines.joined(separator: "\n") + "\n"
}
/// Writes `records` through a temp file and rename.
private func write(_ records: [VerificationRecord]) throws {
let data = try encoder.encode(records)
try AtomicFileWriter.write(data, to: url)
}
private func csvRow(_ fields: [String]) -> String {
fields.map { field in
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") {
return "\"\(escaped)\""
}
return escaped
}.joined(separator: ",")
}
}
@@ -0,0 +1,36 @@
import Foundation
/// A single entry in the verification history store.
public struct VerificationRecord: Sendable, Equatable, Codable, Identifiable {
public var id: String
public var profileName: String
public var printerName: String
public var avgDE: Double
public var maxDE: Double
public var rmsDE: Double
public var patchCount: Int
public var status: VerificationStatus
public var timestamp: Date
public init(
id: String,
profileName: String,
printerName: String,
avgDE: Double,
maxDE: Double,
rmsDE: Double,
patchCount: Int,
status: VerificationStatus,
timestamp: Date
) {
self.id = id
self.profileName = profileName
self.printerName = printerName
self.avgDE = avgDE
self.maxDE = maxDE
self.rmsDE = rmsDE
self.patchCount = patchCount
self.status = status
self.timestamp = timestamp
}
}
@@ -0,0 +1,32 @@
import Foundation
/// ICCery quality band for a verification run.
///
/// Bands are on the **average** ΔE:
/// - < 1.0 Excellent
/// - < 2.0 Good
/// - < 3.5 Acceptable
/// - 3.5 Warning
public enum VerificationStatus: String, Sendable, Equatable, Codable, CaseIterable {
case excellent
case good
case acceptable
case poor
public var displayName: String {
switch self {
case .excellent: return "Excellent"
case .good: return "Good"
case .acceptable: return "Acceptable"
case .poor: return "Warning"
}
}
/// Returns the quality band for the given average ΔE.
public static func from(avgDE: Double) -> VerificationStatus {
if avgDE < 1.0 { return .excellent }
if avgDE < 2.0 { return .good }
if avgDE < 3.5 { return .acceptable }
return .poor
}
}
+50 -46
View File
@@ -1,75 +1,79 @@
#!/bin/bash
# Mock script for chartread -u
# This script simulates the behaviour of chartread for testing purposes.
# Mock chartread for bundled/manual testing.
# Supports handheld and XY modes. Writes basename.ti3 on 'd'.
MODE="${MOCK_CHARTREAD_MODE:-strip}"
BASENAME=""
# Check for --xy argument or MOCK_XY_TABLE environment variable
IS_XY=0
# Basename is the last non-flag argument.
for arg in "$@"; do
if [ "$arg" = "--xy" ]; then
IS_XY=1
break
fi
case "$arg" in
-*) ;;
*) BASENAME="$arg" ;;
esac
done
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
read_input() {
IFS= read -r line || return 1
}
emit_row() {
printf 'ROW_COLORS_JSON: %s\n' "$1"
}
write_ti3() {
if [ -n "$BASENAME" ]; then
echo "MOCK_TI3" > "${BASENAME}.ti3"
fi
}
if [ "$MODE" = "xy" ]; then
echo "Place instrument on calibration tile and hit [Space] to calibrate."
read -r _calib
read_input
echo "Calibration successful."
echo "Please place sheet 1 of 1 on the table"
echo "hit return to continue, Esc or 'q' to give up"
read -r _sheet1
read_input
echo "locate patch A1 with the sight,"
echo "then hit return to continue"
read -r _fid1
echo "locate patch B24 with the sight,"
echo "then hit return to continue"
read -r _fid2
read_input
echo "Reading sheet 1..."
sleep 0.5
# Emit mock JSON for strip A
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
EOF
# Emit mock JSON for strip B
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
EOF
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 1, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
echo "Sheet 1 of 1 read OK"
echo "Please remove last sheet from table"
echo "'d' if/when done"
while read_input; do
case "$line" in
d*) write_ti3; exit 0 ;;
q*) exit 0 ;;
esac
done
exit 0
fi
# Handheld / strip reader simulation
# Handheld / strip mode (default)
echo "Place instrument on calibration tile and hit [Space] to calibrate."
# We don't really wait for input, just wait 1 second
sleep 1
read_input
echo "Calibration successful."
echo "Hit [Space] to read strip A (or 's' to skip)."
sleep 1
echo "Hit [Space] to read strip A"
read_input
echo "Reading strip A..."
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
# Emit mock JSON for strip A
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
EOF
echo "Hit [Space] to read strip B (or 's' to skip)."
sleep 1
echo "Hit [Space] to read strip B"
read_input
echo "Reading strip B..."
emit_row '{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}'
# Emit mock JSON for strip B
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
EOF
echo "Ready to read... done."
echo "'d' if/when done"
while read_input; do
case "$line" in
d*) write_ti3; exit 0 ;;
q*) exit 0 ;;
esac
done
exit 0
+62
View File
@@ -0,0 +1,62 @@
import SwiftUI
import ICCeryCore
/// About dialog for ICCery (issue #31, docs/21 §Modals).
struct AboutView: View {
let onClose: () -> Void
private let info = ArtefactFiles.appInfo()
var body: some View {
VStack(spacing: 20) {
Image("ICCery-logo")
.resizable()
.scaledToFit()
.frame(height: 64)
Text("ICCery")
.font(.title)
.foregroundStyle(Theme.text)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Version:")
.foregroundStyle(.secondary)
Text(info.version)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutVersion")
}
HStack {
Text("Build:")
.foregroundStyle(.secondary)
Text(info.build)
.foregroundStyle(Theme.text)
}
HStack {
Text("Build date:")
.foregroundStyle(.secondary)
Text(info.buildDate)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutBuildDate")
}
}
.font(.callout)
Text("Native macOS printer profiling workstation.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") {
onClose()
}
.controlSize(.large)
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("closeAboutBtn")
}
.padding(32)
.frame(width: 360)
.background(Theme.panel)
.accessibilityIdentifier("aboutDialog")
}
}
+5 -1
View File
@@ -12,6 +12,7 @@ struct AppEnvironment: Sendable {
let presetStore: PresetStore
let runner: ArgyllRunner
let cupsService: CupsService
let historyStore: VerificationHistoryStore
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -38,7 +39,8 @@ struct AppEnvironment: Sendable {
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir)
binaryDir: cupsDir),
historyStore: VerificationHistoryStore()
)
}
}
@@ -66,6 +68,8 @@ enum UITestHooks {
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
/// `select_directory` result (working-directory browse).
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
/// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`).
static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") }
/// Preset import file.
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination.
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
import ICCeryCore
/// Minimal line chart for drift history without depending on the
/// `Charts` framework link. Renders avg/max series with shaded quality
/// bands.
struct DriftChartView: View {
let records: [VerificationRecord]
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
ZStack(alignment: .topLeading) {
if let (_, _, _, maxV) = scales(in: height) {
// Quality bands bottom (red, > 3.5) drawn first, then
// orange, yellow, green so the upper-most bands overlay.
band(from: 3.5, to: maxV, color: .red.opacity(0.12), height: height, maxValue: maxV)
band(from: 2.0, to: 3.5, color: .orange.opacity(0.12), height: height, maxValue: maxV)
band(from: 1.0, to: 2.0, color: .yellow.opacity(0.12), height: height, maxValue: maxV)
band(from: 0.0, to: 1.0, color: .green.opacity(0.12), height: height, maxValue: maxV)
}
if !records.isEmpty, let (minT, maxT, minV, maxV) = scales(in: height) {
// Average ΔE series
Path { path in
for (index, record) in records.enumerated() {
let pt = point(
for: record,
minTime: minT,
maxTime: maxT,
minValue: minV,
maxValue: maxV,
width: width,
height: height,
keyPath: \.avgDE
)
if index == 0 {
path.move(to: pt)
} else {
path.addLine(to: pt)
}
}
}
.stroke(Color.blue, lineWidth: 2)
.accessibilityIdentifier("driftAvgSeries")
// Max ΔE series
Path { path in
for (index, record) in records.enumerated() {
let pt = point(
for: record,
minTime: minT,
maxTime: maxT,
minValue: minV,
maxValue: maxV,
width: width,
height: height,
keyPath: \.maxDE
)
if index == 0 {
path.move(to: pt)
} else {
path.addLine(to: pt)
}
}
}
.stroke(Color.orange, lineWidth: 2)
.accessibilityIdentifier("driftMaxSeries")
} else {
Text("No data")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
private func band(
from lower: Double,
to upper: Double,
color: Color,
height: CGFloat,
maxValue: Double
) -> some View {
let yTop = valueY(lower, minValue: 0, maxValue: maxValue, height: height)
let yBottom = valueY(upper, minValue: 0, maxValue: maxValue, height: height)
return color
.frame(height: yBottom - yTop)
.offset(y: yTop)
}
private func scales(in height: CGFloat) -> (Date, Date, Double, Double)? {
guard let minT = records.first?.timestamp, let maxT = records.last?.timestamp else { return nil }
let maxV = max(records.map { max($0.avgDE, $0.maxDE) }.max() ?? 5.0, 5.0)
return (minT, maxT, 0.0, maxV)
}
private func point(
for record: VerificationRecord,
minTime: Date,
maxTime: Date,
minValue: Double,
maxValue: Double,
width: CGFloat,
height: CGFloat,
keyPath: KeyPath<VerificationRecord, Double>
) -> CGPoint {
let timeSpan = max(1, maxTime.timeIntervalSince(minTime))
let x = width * CGFloat(record.timestamp.timeIntervalSince(minTime) / timeSpan)
let y = valueY(record[keyPath: keyPath], minValue: minValue, maxValue: maxValue, height: height)
return CGPoint(x: x, y: y)
}
private func valueY(_ value: Double, minValue: Double, maxValue: Double, height: CGFloat) -> CGFloat {
let valueSpan = max(1, maxValue - minValue)
return height - height * CGFloat((value - minValue) / valueSpan)
}
}
+31
View File
@@ -0,0 +1,31 @@
import SwiftUI
/// Reusable help overlay badge that does not reflow layout (#171).
///
/// When `showing` is `true`, a small indicator is rendered as an overlay at the
/// top-trailing corner of the wrapped view. The native `.help` tooltip is always
/// available on hover, so the overlay is purely a visual cue in help mode.
struct HelpOverlay: ViewModifier {
let text: String
@Binding var showing: Bool
func body(content: Content) -> some View {
content
.help(text)
.overlay(alignment: .topTrailing) {
if showing {
Image(systemName: "questionmark.circle.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.accent)
.offset(x: 8, y: -8)
}
}
}
}
extension View {
/// Adds a non-reflowing help overlay to the view.
func helpOverlay(_ text: String, showing: Binding<Bool>) -> some View {
modifier(HelpOverlay(text: text, showing: showing))
}
}
@@ -0,0 +1,462 @@
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// A single evaluated swatch for the live grid.
struct Swatch: Sendable, Equatable, Identifiable {
let rowId: String
let loc: String
let isPad: Bool
let intended: DisplayRGB
let measured: DisplayRGB
let deltaE: Double?
let classification: SwatchClassification
var id: String { "\(rowId)\(loc)" }
}
/// A row of swatches in display order.
struct SwatchRow: Sendable, Equatable, Identifiable {
let index: Int
let rowId: String
let patches: [Swatch]
var id: String { rowId }
}
/// Stage of the XY-table badge bar.
enum XYStep: Equatable, Sendable {
case place, align, scan, remove
}
/// Stage 3 workflow state and interaction (issues #18#22).
@MainActor
@Observable
final class MeasurementWorkflowViewModel {
// MARK: - Authorities
let wizard: WizardViewModel
let environment: AppEnvironment
// MARK: - Settings-driven thresholds
private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection
var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false
var detectionError: String?
// MARK: - Chartread session
var isChartreadRunning = false
var chartreadState: ChartreadState = .idle
var currentPrompt: String?
var requestedWarningKey: String?
var chartreadLog: [String] = []
var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false
var lastError: String?
private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging
var passSnapshots: [URL] = []
var isFinishing = false
var finishNotice: String?
var finishNoticeIsError = false
var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
loadSettings()
discoverPassSnapshots()
}
// MARK: - Derived state
var basename: String { wizard.basename }
var workingDirectory: URL? { wizard.effectiveWorkingDirectory }
var canDetect: Bool { !isDetecting }
var canStartRead: Bool {
!basename.isEmpty && workingDirectory != nil && !isChartreadRunning
}
var canMeasureAnotherSheet: Bool {
isFinished && !passSnapshots.isEmpty
}
var canFinish: Bool {
isFinished && !passSnapshots.isEmpty
}
var isFinished: Bool {
chartreadState == .allStripsRead || chartreadState == .finished
}
var xyStep: XYStep {
if showRemoveSheetNotice { return .remove }
switch chartreadState {
case .tablePlaceSheet:
return .place
case .tableAlign:
return .align
case .reading, .awaitingStrip:
return .scan
default:
return .place
}
}
var hasCanonicalTi3: Bool {
guard let cwd = workingDirectory else { return false }
let url = cwd.appendingPathComponent("\(basename).ti3")
return FileManager.default.fileExists(atPath: url.path)
}
// MARK: - Settings
func loadSettings() {
let settings = environment.settingsStore.load()
goodMax = settings.deltaEGoodMax
warningMax = settings.deltaEWarningMax
enableLEDs = settings.enableI1Pro2Leds
}
// MARK: - Instrument detection
func detectInstruments() {
guard !isDetecting else { return }
isDetecting = true
detectionError = nil
Task { @MainActor [weak self] in
guard let self else { return }
do {
let devices = try await self.environment.runner.detectInstruments()
self.instruments = devices
if case .device(let selected) = self.selectedInstrument,
!devices.contains(where: { $0.port == selected.port }) {
self.selectedInstrument = .auto
}
} catch {
self.detectionError = error.localizedDescription
}
self.isDetecting = false
}
}
// MARK: - Chartread lifecycle
func startRead() {
guard canStartRead, let cwd = workingDirectory else { return }
let config = buildChartreadConfig(cwd: cwd)
startChartread(config: config)
}
func measureAnotherSheet() {
guard let cwd = workingDirectory, isFinished else { return }
let config = buildChartreadConfig(cwd: cwd)
startChartread(config: config)
}
private func buildChartreadConfig(cwd: URL) -> ChartreadConfig {
ChartreadConfig(
basename: basename,
workingDirectory: cwd,
selectedPort: selectedInstrument.chartreadPort,
enableLEDs: enableLEDs,
isXY: selectedInstrument.isXY
)
}
private func startChartread(config: ChartreadConfig) {
guard !isChartreadRunning else { return }
isChartreadRunning = true
chartreadState = .idle
currentPrompt = nil
lastError = nil
chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass.
if passSnapshots.isEmpty {
rows.removeAll()
swatchRows.removeAll()
}
let stream = environment.runner.runChartread(config: config)
chartreadTask = Task { @MainActor [weak self] in
guard let self else { return }
for await event in stream {
self.handle(event: event)
}
self.isChartreadRunning = false
}
}
private func handle(event: ChartreadEvent) {
switch event {
case .prompt(let result):
chartreadState = result.state
currentPrompt = promptText(for: result)
requestedWarningKey = result.requestedWarningKey
showRemoveSheetNotice = result.isRemoveSheetNotice
case .row(let row):
upsert(row: row)
if row.isFinalRow {
chartreadState = .allStripsRead
}
case .log(let batch):
chartreadLog.append(contentsOf: batch)
case .removeSheetNotice:
showRemoveSheetNotice = true
case .exit(let code):
if code != 0 {
lastError = "chartread exited with code \(code)"
}
case .completed(let canonicalURL):
chartreadState = .finished
completePass(canonicalURL: canonicalURL)
case .failed(let error):
lastError = error.localizedDescription
chartreadState = .error
isChartreadRunning = false
}
}
private func promptText(for result: ChartreadClassifyResult) -> String {
switch result.state {
case .calibrating:
return "Place instrument on calibration tile and press Calibrate."
case .awaitingStrip:
return "Press a key to read the next strip."
case .allStripsRead:
return "All strips read. Press Done & Save when ready."
case .warning:
if let key = result.requestedWarningKey {
return "Warning — press '\(key.uppercased())' to continue."
}
return "Warning — press Continue."
case .promptContinue:
return "Press Continue."
case .tablePlaceSheet:
if let n = result.sheetNumber, let t = result.sheetTotal {
return "Place sheet \(n) of \(t) on the table."
}
return "Place the sheet on the table."
case .tableAlign:
if let patch = result.alignmentPatch {
return "Locate patch \(patch) with the sight, then continue."
}
return "Align the fiducial, then continue."
case .reading:
return "Reading..."
case .error:
return "Read error — you can Retry or Cancel."
case .finished:
return "Measurement saved."
case .idle:
return "Press Start to begin reading."
}
}
// MARK: - User actions
func calibrate() {
send(.trigger)
}
func accept() {
if let key = requestedWarningKey {
send(.customKey(key))
requestedWarningKey = nil
} else {
send(.accept)
}
}
func retry() {
send(.trigger)
}
func doneAndSave() {
send(.done)
}
func cancelRead() {
environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY)
chartreadTask?.cancel()
isChartreadRunning = false
chartreadState = .idle
currentPrompt = nil
requestedWarningKey = nil
showRemoveSheetNotice = false
}
func sendWarningKey(_ key: String) {
send(.customKey(key))
}
private func send(_ input: ChartreadInput) {
Task { @MainActor [weak self] in
guard let self, self.isChartreadRunning else { return }
try? await self.environment.runner.sendChartreadInput(basename: self.basename, input: input)
}
}
// MARK: - Rows and swatches
private func upsert(row: ChartreadRow) {
if let index = rows.firstIndex(where: { $0.rowIndex == row.rowIndex }) {
rows[index] = row
} else {
rows.append(row)
}
rows.sort { $0.rowIndex < $1.rowIndex }
recomputeSwatches()
}
func recomputeSwatches() {
var displayRows: [SwatchRow] = []
for (rowIndex, row) in rows.enumerated() {
var swatches: [Swatch] = []
for patch in row.patches {
let skip = shouldSkipPad(patch)
if skip { continue }
let eval = ColorDifference.evaluate(
patch: patch,
goodMax: goodMax,
warningMax: warningMax
)
if let eval {
swatches.append(Swatch(
rowId: row.rowId,
loc: patch.loc,
isPad: patch.isPad,
intended: eval.intended,
measured: eval.measured,
deltaE: eval.deltaE,
classification: eval.classification
))
}
}
if !swatches.isEmpty {
displayRows.append(SwatchRow(index: rowIndex, rowId: row.rowId, patches: swatches))
}
}
swatchRows = displayRows
}
private func shouldSkipPad(_ patch: ChartreadPatch) -> Bool {
guard patch.isPad else { return false }
let measuredEmpty = patch.measured.xyz == nil && patch.measured.lab == nil
let deviceAllZero = patch.device.allSatisfy { $0 == 0 }
return measuredEmpty && deviceAllZero
}
// MARK: - Pass management
private func completePass(canonicalURL: URL) {
guard let cwd = workingDirectory else { return }
do {
_ = try MeasurementArtefacts.snapshotPass(basename: basename, cwd: cwd)
discoverPassSnapshots()
wizard.refreshGating()
} catch {
lastError = "Could not snapshot pass: \(error.localizedDescription)"
}
}
func discoverPassSnapshots() {
guard let cwd = workingDirectory else {
passSnapshots = []
return
}
passSnapshots = MeasurementArtefacts.passSnapshots(basename: basename, cwd: cwd)
}
// MARK: - Finish / Average
func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil
finishNoticeIsError = false
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 {
let config = AverageConfig(
workingDirectory: cwd,
basename: self.basename,
passFiles: self.passSnapshots
)
canonical = try await self.environment.runner.runAverage(
config: config,
onLogBatch: { [weak self] batch in
Task { @MainActor [weak self] in
self?.chartreadLog.append(contentsOf: batch)
}
}
)
}
self.discoverPassSnapshots()
self.wizard.refreshGating()
if self.wizard.isUnlocked(.buildProfile) {
self.wizard.go(to: .buildProfile)
} else {
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
}
} catch {
// Fallback to pass 1 promotion if averaging failed.
if let pass = self.passSnapshots.first {
do {
_ = try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
self.discoverPassSnapshots()
self.wizard.refreshGating()
self.finishNotice = "Averaging failed — promoted first pass."
self.finishNoticeIsError = true
} catch {
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
}
} else {
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
}
}
self.isFinishing = false
}
}
}
+1
View File
@@ -43,6 +43,7 @@ struct NoticeBanner: View {
.foregroundStyle(Theme.text)
.lineLimit(3)
.accessibilityIdentifier("noticeText")
.accessibilityValue(notice.text)
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
@@ -0,0 +1,506 @@
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// User-facing FWA selection for the Stage 4 form.
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
case none = "none"
case empty = ""
case D50 = "D50"
case D65 = "D65"
case custom = "custom"
var displayName: String {
switch self {
case .none: return "None"
case .empty: return "Bare (-f)"
case .D50: return "D50"
case .D65: return "D65"
case .custom: return "Custom .sp"
}
}
}
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor
@Observable
final class ProfileWorkflowViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
// MARK: - Stage 4 form
var algorithm: String = "l" // l | x | X | m
var quality: String = "m" // l | m | h | u
var intent: String = "" // usually empty at Stage 4
var fwaSelection: ColprofFwaSelection = .none
var fwaCustomPath: String = ""
var illuminant: String = ""
var observer: String = ""
var inputViewingCond: String = ""
var outputViewingCond: String = ""
var profileDescription: String = ""
var copyright: String = ""
// MARK: - Run state
var isColprofRunning = false
var colprofLog: [String] = []
var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL?
// MARK: - Stage 4/5 calibration (issue #24)
var applyCalibration = false
var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25)
var profcheckReport: ProfcheckReport?
var profcheckWarning: String?
var isProfcheckRunning = false
// MARK: - History / drift (issue #26)
var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil
var driftAlert: String?
var isHistoryStoreError: String?
// MARK: - Install (issue #27)
var installResult: InstallProfileResult?
var showingInstallCollision = false
var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
restoreCreatedProfileURL()
}
/// Restores `createdProfileURL` from the wizard artefacts or by probing
/// the working directory for an existing `.icc`/`.icm` (#52).
func restoreCreatedProfileURL() {
let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil)
createdProfileURL = wizard.artefacts.profilePath
?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd)
}
// MARK: - Derived
var canCreateProfile: Bool {
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isColprofRunning
}
var canVerify: Bool {
createdProfileURL != nil && !isProfcheckRunning
}
var fwaValue: String? {
switch fwaSelection {
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return fwaCustomPath
}
}
// MARK: - Preset application
func applyPreset(_ preset: ProfilingPreset?) {
guard let preset else { return }
algorithm = preset.colprofAlgorithm ?? "l"
quality = preset.colprofQuality ?? "m"
intent = preset.colprofIntent ?? ""
if let fwa = preset.colprofFwa {
switch fwa.lowercased() {
case "none": fwaSelection = .none
case "": fwaSelection = .empty
case "d50": fwaSelection = .D50
case "d65": fwaSelection = .D65
default:
fwaSelection = .custom
fwaCustomPath = fwa
}
}
illuminant = preset.colprofIlluminant ?? ""
observer = preset.colprofObserver ?? ""
inputViewingCond = preset.colprofInputViewingCond ?? ""
outputViewingCond = preset.colprofOutputViewingCond ?? ""
}
/// Stage 4 form values for saving into a custom preset.
func presetSnapshot() -> (
algorithm: String,
quality: String,
intent: String?,
fwa: String?,
illuminant: String?,
observer: String?,
inputViewingCond: String?,
outputViewingCond: String?
) {
(
algorithm: algorithm,
quality: quality,
intent: intent.isEmpty ? nil : intent,
fwa: fwaValue,
illuminant: illuminant.isEmpty ? nil : illuminant,
observer: observer.isEmpty ? nil : observer,
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond
)
}
// MARK: - Stage 4: build profile
func buildColprofConfig() -> ColprofConfig {
let description = profileDescription.isEmpty ? wizard.basename : profileDescription
return ColprofConfig(
algorithm: algorithm,
quality: quality,
intent: intent.isEmpty ? nil : intent,
fwa: fwaValue,
illuminant: illuminant.isEmpty ? nil : illuminant,
observer: observer.isEmpty ? nil : observer,
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond,
description: description,
copyright: copyright.isEmpty ? nil : copyright,
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
)
}
func createProfile() {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil
lastError = nil
createdProfileURL = 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) { [weak self] batch in
Task { @MainActor [weak self] in
guard let self else { return }
self.colprofLog.append(contentsOf: batch)
if let last = batch.last {
let progress = ColprofProgressClassifier.classify(line: last)
self.updateProgress(progress)
}
}
}
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)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
_ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch)
}
}
self.colprofLog.append("Gamut mesh extracted.")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
self.createdProfileURL = finalProfileURL
self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
private func updateProgress(_ progress: ColprofProgress) {
switch progress {
case .gamutMapping:
colprofProgress = "Gamut mapping calculation…"
case .fittingClut:
colprofProgress = "Fitting cLUT grid points…"
case .writingIcc:
colprofProgress = "Writing ICC profile…"
case .unknown:
break
}
}
// MARK: - Stage 5: verify profile
var knownPrinters: [String] {
var names = Set<String>()
for record in verificationHistory {
if record.printerName.isEmpty {
names.insert("Unknown")
} else {
names.insert(record.printerName)
}
}
return Array(names).sorted()
}
func loadHistory() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
self.verificationHistory = try await self.environment.historyStore.load()
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
} catch {
self.isHistoryStoreError = error.localizedDescription
self.wizard.showNotice(
"Could not load verification history: \(error.localizedDescription)",
kind: .error
)
}
}
}
var filteredHistory: [VerificationRecord] {
guard let filter = driftPrinterFilter, !filter.isEmpty else {
return verificationHistory
}
return verificationHistory.filter { $0.printerName == filter }
}
func verifyProfile() {
guard canVerify,
let cwd = wizard.effectiveWorkingDirectory,
let profileURL = createdProfileURL else { return }
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) { [weak self] batch in
Task { @MainActor [weak self] 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
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
}
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
self.profcheckWarning = "profcheck output could not be parsed."
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
} catch {
self.profcheckWarning = error.localizedDescription
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
self.wizard.showNotice(
"Verification failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
func makeVerificationRecord(from report: ProfcheckReport) -> VerificationRecord? {
guard let avg = report.avgDE,
let max = report.maxDE,
let rms = report.rmsDE,
let status = report.status else { return nil }
let timestamp = Date()
let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())"
let printerName = wizard.printerName?.isEmpty == false ? wizard.printerName! : "Unknown"
return VerificationRecord(
id: id,
profileName: createdProfileURL?.lastPathComponent ?? wizard.basename,
printerName: printerName,
avgDE: avg,
maxDE: max,
rmsDE: rms,
patchCount: report.patchCount ?? 0,
status: status,
timestamp: timestamp
)
}
private static func nextSeq() -> Int {
Int.random(in: 0..<1_000_000)
}
func clearHistory() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
try await self.environment.historyStore.clear()
self.verificationHistory = []
self.driftAlert = nil
} catch {
self.wizard.showNotice(
"Could not clear history: \(error.localizedDescription)",
kind: .error
)
}
}
}
// MARK: - Profile install
func beginInstallProfile() {
guard let sourceURL = createdProfileURL,
let _ = wizard.effectiveWorkingDirectory else { return }
let settings = environment.settingsStore.load()
let preferSystem = settings.defaultInstallLocation == .system
let options = InstallProfileOptions(
forceOverwrite: !settings.askBeforeOverwriteProfile,
preferSystem: preferSystem,
collisionPolicy: .overwrite,
openColorPanel: settings.openColorPanelAfterInstall
)
do {
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
let destURL = try ProfileInstaller.resolveDestinationURL(for: config)
let collision = FileManager.default.fileExists(atPath: destURL.path)
if collision && settings.askBeforeOverwriteProfile {
pendingInstallOptions = options
installCollisionMessage = "A profile named \(destURL.lastPathComponent) already exists."
showingInstallCollision = true
return
}
runInstall(sourceURL: sourceURL, options: options)
} catch {
wizard.showNotice(
"Install failed: \(error.localizedDescription)",
kind: .error
)
}
}
func resolveInstallCollision(policy: ProfileCollisionPolicy) {
showingInstallCollision = false
guard let sourceURL = createdProfileURL,
var options = pendingInstallOptions else { return }
if policy == .cancel {
installResult = InstallProfileResult(
destPath: "",
registered: false,
overwritten: false,
renamed: false,
openedPanel: false,
message: "Install cancelled."
)
return
}
options.collisionPolicy = policy
if policy == .overwrite {
options.forceOverwrite = true
}
runInstall(sourceURL: sourceURL, options: options)
}
private func runInstall(sourceURL: URL, options: InstallProfileOptions) {
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
Task(priority: .userInitiated) { [weak self] in
do {
let result = try ProfileInstaller.install(config: config)
await MainActor.run { [weak self] in
self?.installResult = result
self?.wizard.showNotice(result.message)
}
} catch {
await MainActor.run { [weak self] in
self?.wizard.showNotice(
"Install failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
}
func exportHistory() {
guard let url = fileDialogs.selectCsvSavePath() else { return }
Task { @MainActor [weak self] in
guard let self else { return }
let csv = await self.environment.historyStore.exportCSV()
do {
try csv.write(to: url, atomically: true, encoding: .utf8)
self.wizard.showNotice("History exported: \(url.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Export failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
// MARK: - File pickers
func browseForSpectrumFile() {
let start = wizard.effectiveWorkingDirectory
let url = UITestHooks.isEnabled
? nil
: fileDialogs.selectSpectrumFile(startingAt: start)
if let url {
fwaSelection = .custom
fwaCustomPath = url.path
}
}
func browseForCalibrationFile() {
let start = wizard.effectiveWorkingDirectory
let url = fileDialogs.selectCalFile(startingAt: start)
if let url {
calibrationFile = url.path
applyCalibration = true
}
}
}
+10 -20
View File
@@ -8,6 +8,7 @@ struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel
@State private var showingSettings = false
@State private var showingAbout = false
@State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard }
@@ -16,7 +17,8 @@ struct RootView: View {
SidebarView(
workflow: workflow,
onOpenSettings: { showingSettings = true },
onOpenAbout: { showingAbout = true }
onOpenAbout: { showingAbout = true },
showingAllHelp: $showingAllHelp
)
Rectangle()
@@ -48,10 +50,8 @@ struct RootView: View {
.sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow)
}
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
Button("OK") {}
} message: {
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
.sheet(isPresented: $showingAbout) {
AboutView { showingAbout = false }
}
}
@@ -63,21 +63,11 @@ struct RootView: View {
case .layOutPrint:
Stage2View(workflow: workflow)
case .measure:
// Stage 3 stays a shell until M4, but a .ti2 resume still
// lands here show the persisted state (#8, issue #140).
VStack(spacing: 16) {
if workflow.resumedFromTi2 {
Label("Resumed from .ti2", systemImage: "arrow.uturn.right")
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("stage3LoadedTargetBanner")
}
Text(model.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage3TargetBasename")
StagePlaceholderView(stage: model.stage)
}
Stage3View(model: workflow.measurement)
case .buildProfile:
Stage4View(model: workflow.profile)
case .verifyInstall:
Stage5View(model: workflow.profile)
default:
StagePlaceholderView(stage: model.stage)
}
+11 -2
View File
@@ -7,6 +7,7 @@ struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard }
@@ -22,12 +23,20 @@ struct SidebarView: View {
Image(systemName: "gearshape")
}
.buttonStyle(.plain)
.help("Settings")
.helpOverlay("Open the Settings dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openSettingsBtn")
Button(action: onOpenAbout) {
Image(systemName: "info.circle")
}
.buttonStyle(.plain)
.help("About ICCery")
.helpOverlay("Open the About dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openAboutBtn")
Button(action: { showingAllHelp.toggle() }) {
Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle")
}
.buttonStyle(.plain)
.help("Toggle help overlays")
.accessibilityIdentifier("btnToggleAllHelp")
}
.padding(12)
+1 -2
View File
@@ -87,9 +87,8 @@ struct Stage1View: View {
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
Button("Open Existing…") { workflow.openExistingTarget() }
.accessibilityIdentifier("btnOpenExisting")
Button("Import Dataset…") { /* CGATS import #94, later */ }
Button("Import Dataset…") { workflow.importMeasurementDataset() }
.accessibilityIdentifier("btn-import-dataset")
.disabled(true)
}
Text(workflow.targetDirectory?.path ?? "No working directory selected")
.font(.caption)
+1 -2
View File
@@ -230,11 +230,10 @@ struct Stage2View: View {
.foregroundStyle(workflow.printNoticeIsError
? .red : .secondary)
.accessibilityIdentifier("printNotificationText")
.accessibilityValue(notice)
}
Spacer()
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("printNotification")
// Printer row: select + status + refresh + Preferences.
HStack(spacing: 10) {
+385
View File
@@ -0,0 +1,385 @@
import Combine
import SwiftUI
import ICCeryCore
/// Stage 3 measurement, live swatches, and multi-pass averaging.
struct Stage3View: View {
@Bindable var model: MeasurementWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
instrumentSection
chartreadControlsSection
xyTableSection
swatchGridSection
averagingSection
}
.padding(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
.onAppear { model.discoverPassSnapshots() }
.onReceive(NotificationCenter.default.publisher(for: SettingsStore.settingsDidChange)) { _ in
model.loadSettings()
model.recomputeSwatches()
}
}
// MARK: - Header
@ViewBuilder
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
if model.resumedFromTi2 {
Label("Resumed from .ti2", systemImage: "arrow.uturn.right")
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("stage3LoadedTargetBanner")
}
Text(model.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage3TargetBasename")
Text("Measure the printed chart with a spectrophotometer.")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage3TargetMeta")
}
Spacer()
if model.isChartreadRunning {
ProgressView()
.scaleEffect(0.8)
.accessibilityIdentifier("readProgress")
}
if let badge = targetBadge {
Text(badge)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.border)
.cornerRadius(4)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage3TargetBadge")
}
}
.padding(16)
.background(Theme.panel)
}
private var targetBadge: String? {
if model.isFinished { return "All strips read" }
if model.isChartreadRunning { return "Reading" }
if !model.passSnapshots.isEmpty { return "\(model.passSnapshots.count) pass(es)" }
return nil
}
// MARK: - Instrument detection
@ViewBuilder
private var instrumentSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Instrument")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button(action: { model.detectInstruments() }) {
Image(systemName: "arrow.clockwise")
}
.disabled(!model.canDetect)
.accessibilityIdentifier("btnDetectInstruments")
}
if model.detectionError != nil {
Text(model.detectionError ?? "")
.font(.caption)
.foregroundStyle(.red)
}
Picker("Instrument", selection: Binding(
get: { instrumentTag },
set: { newTag in
if newTag.isEmpty {
model.selectedInstrument = .auto
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
model.selectedInstrument = .device(device)
}
}
)) {
Text("Auto (first available port)").tag("")
ForEach(model.instruments) { device in
Text(device.displayName).tag("\(device.port)")
}
}
.pickerStyle(.menu)
.accessibilityIdentifier("chartreadInstrumentSelect")
if model.selectedInstrument.isXY {
Text("XY table workflow selected.")
.font(.caption)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("xyTableHint")
}
}
.padding(16)
.background(Theme.panel)
}
private var instrumentTag: String {
switch model.selectedInstrument {
case .auto:
return ""
case .device(let device):
return "\(device.port)"
}
}
// MARK: - Chartread controls
@ViewBuilder
private var chartreadControlsSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Status")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text(model.currentPrompt ?? "Press Start to begin reading.")
.font(.callout)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadPrompt")
}
if model.showRemoveSheetNotice {
Text("Please remove last sheet from table.")
.font(.caption)
.foregroundStyle(Theme.accent)
}
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("chartreadLastError")
.accessibilityValue(lastError)
}
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")
}
}
.padding(16)
.background(Theme.panel)
}
@ViewBuilder
private var controlButtons: some View {
HStack(spacing: 12) {
if !model.isChartreadRunning {
Button("Start Read") {
model.startRead()
}
.disabled(!model.canStartRead)
.accessibilityIdentifier("btnStartRead")
}
if model.isChartreadRunning {
switch model.chartreadState {
case .calibrating:
Button("Calibrate") { model.calibrate() }
.accessibilityIdentifier("btnCalibrate")
case .awaitingStrip:
Button("Trigger") { model.calibrate() }
.accessibilityIdentifier("btnTrigger")
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneReadEarly")
case .tablePlaceSheet, .tableAlign, .promptContinue, .warning:
Button(continueTitle) { model.accept() }
.accessibilityIdentifier("btnAccept")
case .error:
Button("Retry") { model.retry() }
.accessibilityIdentifier("btnRetry")
case .allStripsRead:
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneRead")
default:
EmptyView()
}
Button("Cancel") { model.cancelRead() }
.accessibilityIdentifier("btnCancel")
}
}
}
private var continueTitle: String {
if let key = model.requestedWarningKey {
return "Continue (send '\(key.uppercased())')"
}
return "Continue"
}
// MARK: - XY table badges
@ViewBuilder
private var xyTableSection: some View {
if model.selectedInstrument.isXY {
HStack(spacing: 8) {
xyStep("Place", active: model.xyStep == .place, id: "xyStepPlace")
xyStep("Align", active: model.xyStep == .align, id: "xyStepAlign")
xyStep("Scan", active: model.xyStep == .scan, id: "xyStepScan")
xyStep("Remove", active: model.xyStep == .remove, id: "xyStepRemove")
}
.padding(12)
.background(Theme.panel)
.accessibilityIdentifier("xyTablePanel")
}
}
private func xyStep(_ label: String, active: Bool, id: String) -> some View {
Text(label)
.font(.caption)
.fontWeight(active ? .bold : .regular)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(active ? Theme.accent : Theme.border)
.foregroundStyle(active ? Color.white : Theme.text)
.cornerRadius(4)
.accessibilityIdentifier(id)
}
// MARK: - Swatch grid
@ViewBuilder
private var swatchGridSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Swatches")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
statsView
}
ScrollView([.horizontal, .vertical]) {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.swatchRows) { row in
HStack(spacing: 2) {
Text(row.rowId)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 24)
ForEach(row.patches) { swatch in
SwatchPatchView(swatch: swatch)
}
}
}
}
.padding(8)
}
.frame(minHeight: 120, maxHeight: 360)
.background(Theme.panel)
.accessibilityIdentifier("swatchGrid")
}
.padding(16)
.background(Theme.background)
}
@ViewBuilder
private var statsView: some View {
let patches = model.swatchRows.flatMap(\.patches)
let valid = patches.compactMap(\.deltaE)
let avg = valid.isEmpty ? nil : valid.reduce(0, +) / Double(valid.count)
let max = valid.max() ?? 0
HStack(spacing: 12) {
if let avg = avg {
Text("avg ΔE \(String(format: "%.2f", avg))")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("max ΔE \(String(format: "%.2f", max))")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(patches.count) patches")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("readStats")
}
// MARK: - Averaging
@ViewBuilder
private var averagingSection: some View {
if !model.passSnapshots.isEmpty || model.isFinished {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Averaging")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text("\(model.passSnapshots.count) pass(es)")
.font(.caption)
.foregroundStyle(Theme.text)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.border)
.cornerRadius(4)
.accessibilityIdentifier("passCounterBadge")
}
ForEach(model.passSnapshots, id: \.lastPathComponent) { url in
Text(url.lastPathComponent)
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("passesList")
HStack(spacing: 12) {
Button("Measure Another Sheet") {
model.measureAnotherSheet()
}
.disabled(!model.isFinished || model.isChartreadRunning)
.accessibilityIdentifier("btnMeasureAnotherSheet")
Button("Finish & Average") {
model.finishAndAverage()
}
.disabled(!model.canFinish || model.isFinishing)
.accessibilityIdentifier("btnFinishAndAverage")
}
if let notice = model.finishNotice {
Text(notice)
.font(.caption)
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
}
}
.padding(16)
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("chartreadAveragingPanel")
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import SwiftUI
import ICCeryCore
/// Stage 4 build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View {
@Bindable var model: ProfileWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
formSection
runSection
}
.padding(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
.onAppear { model.restoreCreatedProfileURL() }
}
// MARK: - Header
@ViewBuilder
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(model.wizard.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage4TargetBasename")
Text("Build the ICC profile from the measured .ti3.")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage4TargetMeta")
}
Spacer()
if let progress = model.colprofProgress, model.isColprofRunning {
Text(progress)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.border)
.cornerRadius(4)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofProgress")
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Form
@ViewBuilder
private var formSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Profile settings")
.font(.headline)
.foregroundStyle(Theme.text)
HStack(spacing: 16) {
Picker("Algorithm", selection: $model.algorithm) {
Text("Lab cLUT").tag("l")
Text("XYZ cLUT").tag("x")
Text("Display XYZ+matrix").tag("X")
Text("Matrix").tag("m")
}
.accessibilityIdentifier("colprofAlgorithm")
Picker("Quality", selection: $model.quality) {
Text("Low").tag("l")
Text("Medium").tag("m")
Text("High").tag("h")
Text("Ultra").tag("u")
}
.accessibilityIdentifier("colprofQuality")
}
Picker("FWA / OBA compensation", selection: $model.fwaSelection) {
ForEach(ColprofFwaSelection.allCases, id: \.self) { selection in
Text(selection.displayName).tag(selection)
}
}
.accessibilityIdentifier("colprofFwa")
if model.fwaSelection == .custom {
HStack {
TextField("Custom .sp spectrum path", text: $model.fwaCustomPath)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofFwaCustomPath")
Button("Browse…") { model.browseForSpectrumFile() }
.accessibilityIdentifier("btnBrowseFwaSp")
}
}
HStack(spacing: 16) {
TextField("Illuminant", text: $model.illuminant)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofIlluminant")
TextField("Observer", text: $model.observer)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofObserver")
}
HStack(spacing: 16) {
TextField("Input viewing condition", text: $model.inputViewingCond)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofInputViewCond")
TextField("Output viewing condition", text: $model.outputViewingCond)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofOutputViewCond")
}
Text("Use 'none' to skip a viewing condition.")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Description", text: $model.profileDescription)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofDescription")
TextField("Copyright", text: $model.copyright)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCopyright")
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
if model.applyCalibration {
HStack {
TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile")
}
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Run controls
@ViewBuilder
private var runSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Button("Create Profile") {
model.createProfile()
}
.disabled(!model.canCreateProfile)
.accessibilityIdentifier("btnCreateProfile")
if model.isColprofRunning {
ProgressView()
.scaleEffect(0.8)
.accessibilityIdentifier("colprofProgressIndicator")
}
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")
}
}
.padding(16)
.background(Theme.panel)
}
}
+275
View File
@@ -0,0 +1,275 @@
import SwiftUI
import ICCeryCore
/// Stage 5 verify the generated profile, track drift, and install.
struct Stage5View: View {
@Bindable var model: ProfileWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
verifySection
if let report = model.profcheckReport {
resultSection(report: report)
}
historySection
}
.padding(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
.onAppear {
model.restoreCreatedProfileURL()
model.loadHistory()
}
.alert("Install profile", isPresented: $model.showingInstallCollision) {
Button("Overwrite", role: .destructive) {
model.resolveInstallCollision(policy: .overwrite)
}
.accessibilityIdentifier("profileOverwriteBtn")
Button("Rename") {
model.resolveInstallCollision(policy: .rename)
}
.accessibilityIdentifier("profileRenameBtn")
Button("Cancel", role: .cancel) {
model.resolveInstallCollision(policy: .cancel)
}
.accessibilityIdentifier("profileCancelCollisionBtn")
} message: {
Text(model.installCollisionMessage)
.accessibilityIdentifier("profileInstallCollisionMessage")
}
}
// MARK: - Header
@ViewBuilder
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(model.wizard.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage5TargetBasename")
Text("Verify the profile and compare against historical results.")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage5TargetMeta")
}
Spacer()
if let alert = model.driftAlert {
Text(alert)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red.opacity(0.2))
.cornerRadius(4)
.foregroundStyle(.red)
.accessibilityIdentifier("driftAlert")
}
if let warning = model.profcheckWarning, !warning.isEmpty {
Text("\(warning)")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red.opacity(0.2))
.cornerRadius(4)
.foregroundStyle(.red)
.accessibilityIdentifier("profcheckWarningBanner")
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Verify
@ViewBuilder
private var verifySection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Button("Verify Profile") {
model.verifyProfile()
}
.disabled(!model.canVerify)
.accessibilityIdentifier("btnVerifyProfile")
if model.isProfcheckRunning {
ProgressView()
.scaleEffect(0.8)
.accessibilityIdentifier("profcheckProgressIndicator")
}
Spacer()
if let profileURL = model.createdProfileURL {
Text(profileURL.lastPathComponent)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage5ProfilePath")
}
}
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("profcheckLogContainer")
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Result cards
@ViewBuilder
private func resultSection(report: ProfcheckReport) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Verification result")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button("Install Profile") { model.beginInstallProfile() }
.disabled(model.createdProfileURL == nil)
.accessibilityIdentifier("btnInstallProfile")
}
HStack(spacing: 16) {
metricCard(title: "Avg ΔE", value: report.avgDE)
metricCard(title: "Max ΔE", value: report.maxDE)
metricCard(title: "RMS", value: report.rmsDE)
metricCard(title: "Patches", value: report.patchCount.map(Double.init))
}
if let status = report.status {
HStack {
Text("Status")
Spacer()
Text(status.displayName)
.fontWeight(.semibold)
.foregroundStyle(statusColor(status))
.accessibilityIdentifier("profcheckStatus")
}
}
}
.padding(16)
.background(Theme.panel)
}
@ViewBuilder
private func metricCard(title: String, value: Double?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value.map { String(format: "%.2f", $0) } ?? "")
.font(.title3)
.foregroundStyle(Theme.text)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - History
@ViewBuilder
private var historySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("History & drift")
.font(.headline)
.foregroundStyle(Theme.text)
HStack {
Picker("Printer", selection: Binding(
get: { model.driftPrinterFilter ?? "" },
set: { model.driftPrinterFilter = $0.isEmpty ? nil : $0 }
)) {
Text("All").tag("")
ForEach(model.knownPrinters, id: \.self) { printer in
Text(printer.isEmpty ? "Unknown" : printer).tag(printer)
}
}
.accessibilityIdentifier("driftPrinterFilter")
.frame(width: 200)
Spacer()
Button("Export CSV") { model.exportHistory() }
.accessibilityIdentifier("btnExportHistory")
Button("Clear") { model.clearHistory() }
.accessibilityIdentifier("btnClearHistory")
}
driftChart
if !model.filteredHistory.isEmpty {
Table(of: VerificationRecord.self) {
TableColumn("Date") { record in
Text(record.timestamp.formatted(date: .numeric, time: .shortened))
}
TableColumn("Profile") { record in
Text(record.profileName)
}
TableColumn("Avg") { record in
Text(String(format: "%.2f", record.avgDE))
}
TableColumn("Max") { record in
Text(String(format: "%.2f", record.maxDE))
}
TableColumn("RMS") { record in
Text(String(format: "%.2f", record.rmsDE))
}
TableColumn("Status") { record in
Text(record.status.displayName)
.foregroundStyle(statusColor(record.status))
}
} rows: {
ForEach(model.filteredHistory) { record in
TableRow(record)
}
}
.frame(minHeight: 120)
.accessibilityIdentifier("verificationHistoryTable")
} else {
Text("No verification records yet.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(16)
.background(Theme.panel)
}
@ViewBuilder
private var driftChart: some View {
let records = model.filteredHistory.sorted { $0.timestamp < $1.timestamp }
DriftChartView(records: records)
.frame(height: 160)
.accessibilityIdentifier("driftChart")
}
private func statusColor(_ status: VerificationStatus) -> Color {
switch status {
case .excellent, .good: return .green
case .acceptable: return .yellow
case .poor: return .red
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import SwiftUI
import ICCeryCore
private extension DisplayRGB {
var color: Color {
Color(red: r, green: g, blue: b)
}
}
/// One swatch in the live grid, with a 135° intended/measured diagonal split.
struct SwatchPatchView: View {
let swatch: Swatch
private var indicatorColor: Color {
switch swatch.classification {
case .good: return .green
case .warning: return .yellow
case .bad: return .red
}
}
var body: some View {
ZStack {
// Background: measured
swatch.measured.color
.clipShape(DiagonalClip(side: .bottomRight))
// Foreground: intended
swatch.intended.color
.clipShape(DiagonalClip(side: .topLeft))
// Classification dot
Circle()
.fill(indicatorColor)
.frame(width: 6, height: 6)
.offset(x: 6, y: 6)
}
.frame(width: 32, height: 32)
.overlay(
Rectangle()
.stroke(Color.primary.opacity(0.2), lineWidth: 0.5)
)
.accessibilityIdentifier("swatch-\(swatch.rowId)\(swatch.loc)")
.accessibilityLabel("\(swatch.loc) intended \(String(format: "%.0f", swatch.intended.r * 255)), measured \(String(format: "%.0f", swatch.measured.r * 255))")
}
}
/// 135° diagonal clipping: top-left or bottom-right triangle.
///
/// A 135° line from the top-right corner to the bottom-left corner gives
/// top-left and bottom-right triangles.
private enum DiagonalSide {
case topLeft
case bottomRight
}
private struct DiagonalClip: Shape {
let side: DiagonalSide
func path(in rect: CGRect) -> Path {
var path = Path()
switch side {
case .topLeft:
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
case .bottomRight:
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
}
path.closeSubpath()
return path
}
}
+67 -3
View File
@@ -119,9 +119,24 @@ final class TargetWorkflowViewModel {
var savePresetName = ""
var savePresetDesc = ""
/// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes.
var measurement: MeasurementWorkflowViewModel
/// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can apply preset values.
var profile: ProfileWorkflowViewModel
init(environment: AppEnvironment = .live()) {
self.environment = environment
self.wizard = WizardViewModel(stateStore: environment.stateStore)
self.measurement = MeasurementWorkflowViewModel(
wizard: wizard,
environment: environment
)
self.profile = ProfileWorkflowViewModel(
wizard: wizard,
environment: environment
)
reloadPresets()
}
@@ -228,6 +243,43 @@ final class TargetWorkflowViewModel {
// MARK: - Issue 8: resume an existing target
/// `#btn-import-dataset` open a measured dataset, write a canonical
/// `.ti3` to the working directory, and set the target (issue #30).
func importMeasurementDataset() {
let url = UITestHooks.isEnabled
? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile()
guard let url else { return }
do {
let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
wizard.showNotice("Choose a working directory before importing.", kind: .warning)
return
}
let stem = url.deletingPathExtension().lastPathComponent
let output = directory.appendingPathComponent("\(stem).ti3")
try CGATSWriter.write(dataset, to: output)
wizard.setTarget(basename: stem, workingDirectory: directory)
wizard.refreshGating()
wizard.showNotice("Imported \(dataset.samples.count) patches from \(url.lastPathComponent)")
if wizard.isUnlocked(.verifyInstall) {
wizard.go(to: .verifyInstall)
} else if wizard.isUnlocked(.buildProfile) {
wizard.go(to: .buildProfile)
} else {
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
}
} catch let error as CGATSParseError {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
} catch {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
}
}
/// `#btnOpenExisting` open `.ti1`/`.ti2` (open dialog, #103).
/// `.ti1` Stage 2; `.ti2` Stage 3 with the resume notice, but
/// only when the sibling `.ti1` exists so the artefact gate holds.
@@ -249,6 +301,7 @@ final class TargetWorkflowViewModel {
wizard.setTarget(basename: stem, workingDirectory: dir)
wizard.refreshGating()
resumedFromTi2 = false
measurement.resumedFromTi2 = false
wizard.go(to: .layOutPrint)
case "ti2":
let header = Ti2Header.parse(url)
@@ -261,6 +314,7 @@ final class TargetWorkflowViewModel {
wizard.setTarget(basename: stem, workingDirectory: dir)
wizard.refreshGating()
resumedFromTi2 = true
measurement.resumedFromTi2 = true
wizard.showNotice("Resumed from .ti2", kind: .info, autoHideAfter: nil)
wizard.go(to: .measure)
default:
@@ -411,8 +465,6 @@ final class TargetWorkflowViewModel {
}
}
/// `#btnPrintAll` spool every gallery TIFF, sequentially. Stops on
/// the first failure so the user sees which page failed.
/// `#btnPrintAll` spool every gallery TIFF, sequentially. Stops on
/// the first failure so the user sees which page failed.
func printAllPages() {
@@ -527,6 +579,8 @@ final class TargetWorkflowViewModel {
}
customSeed = preset.randomSeed ?? 1
profile.applyPreset(preset)
selectedPresetID = preset.id
}
@@ -563,7 +617,17 @@ final class TargetWorkflowViewModel {
bitDepth: bitDepth.rawValue,
dpi: tiffDpi,
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
noRandomize: layoutOrder == .raster
noRandomize: layoutOrder == .raster,
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
applyCalibration: profile.applyCalibration ? true : nil,
colprofAlgorithm: profile.algorithm,
colprofQuality: profile.quality,
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
colprofFwa: profile.fwaValue,
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
)
do {
try environment.presetStore.saveCustom(preset)
@@ -0,0 +1,30 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ApplycalArgs")
struct ApplycalArgsTests {
@Test("Apply argv")
func applyArgv() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
)
let args = try ApplycalArgs.build(config: config)
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
}
@Test("Unapply is emitted when the caller explicitly sets it")
func unapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
unapply: true
)
let args = try ApplycalArgs.build(config: config)
// Builder emits -u only when the caller explicitly sets unapply.
// The UI layer never passes unapply: true in v2.0.
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
}
}
@@ -0,0 +1,52 @@
import Foundation
import Testing
@testable import ICCeryCore
final class LogHolder: @unchecked Sendable {
private let lock = NSLock()
private var _lines: [String] = []
func append(_ batch: [String]) {
lock.lock()
_lines.append(contentsOf: batch)
lock.unlock()
}
var lines: [String] {
lock.lock()
defer { lock.unlock() }
return _lines
}
}
@Suite("ArgyllRunner colprof")
struct ArgyllRunnerColprofTests {
@Test("Mock colprof produces .icc")
func colprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
let testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let runner = ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir)
)
let holder = LogHolder()
let config = ColprofConfig(basename: "testrun", workingDirectory: testRoot)
let url = try await runner.runColprof(config: config) { batch in
holder.append(batch)
}
#expect(url.lastPathComponent == "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot)
}
}
@@ -0,0 +1,134 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("CGATS Parser & Writer")
struct CGATSParserTests {
private static let canonicalCTI3 = """
CTI3
DESCRIPTOR "Sample target"
COLOR_REP "RGB"
DEVICE_CLASS "DISPLAY"
NUMBER_OF_FIELDS 11
NUMBER_OF_SETS 2
BEGIN_DATA_FORMAT
SAMPLE_ID\tSAMPLE_LOC\tRGB_R\tRGB_G\tRGB_B\tXYZ_X\tXYZ_Y\tXYZ_Z\tLAB_L\tLAB_A\tLAB_B
END_DATA_FORMAT
BEGIN_DATA
1\tA1\t50.0\t0.0\t0.0\t20.0\t10.0\t5.0\t50.0\t60.0\t30.0
2\tA2\t0.0\t50.0\t0.0\t10.0\t30.0\t5.0\t60.0\t-50.0\t40.0
END_DATA
"""
@Test("Parses CTI3 with canonical field names")
func parseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3)
#expect(dataset.samples.count == 2)
#expect(dataset.colorRep == "RGB")
#expect(dataset.deviceClass == "DISPLAY")
#expect(dataset.samples[0].id == "1")
#expect(dataset.samples[0].loc == "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
}
@Test("Round-trips parse, write, reparse")
func roundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text)
#expect(second.format == first.format)
#expect(second.samples.count == first.samples.count)
#expect(second.colorRep == first.colorRep)
#expect(second.deviceClass == first.deviceClass)
}
@Test("Parses CSV with comma delimiters")
func parseCSV() throws {
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
#expect(dataset.format == .csv)
#expect(dataset.samples.count == 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
}
@Test("Converts 0-255 device values to 0-100")
func converts255To100() throws {
let rgb = """
CTI3
COLOR_REP RGB
NUMBER_OF_FIELDS 6
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y
END_DATA_FORMAT
BEGIN_DATA
1 255 128 0 50 25
END_DATA
"""
let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
}
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
func synthesizesMetadata() throws {
let cmyk = """
CTI3
NUMBER_OF_FIELDS 6
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K LAB_L
END_DATA_FORMAT
BEGIN_DATA
1 50 50 50 50 50
END_DATA
"""
let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK")
#expect(dataset.deviceClass == "PRINTER")
}
@Test("Rejects empty file")
func rejectsEmpty() {
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
}
@Test("Rejects malformed arity")
func rejectsArity() {
let bad = """
CTI3
NUMBER_OF_FIELDS 2
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R
END_DATA_FORMAT
BEGIN_DATA
1
END_DATA
"""
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse(bad)
}
}
@Test("Writer emits valid .ti3 with tabs and required keywords")
func writerFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset)
#expect(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t"))
}
}
@@ -0,0 +1,72 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ColprofArgs")
struct ColprofArgsTests {
@Test("Default algorithm and quality")
func defaults() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
}
@Test("FWA bare -f when empty string")
func fwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
}
@Test("FWA D50 and D65 emit -f value")
func fwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-f"))
#expect(args.contains("D50"))
#expect(args.last == "target")
}
@Test("FWA none is omitted")
func fwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-f"))
}
@Test("Viewing conditions skip none")
func viewingCondNoneSkipped() throws {
let config = ColprofConfig(
inputViewingCond: "none",
outputViewingCond: "mt",
basename: "target"
)
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-c"))
#expect(args.contains("-d"))
#expect(args.contains("mt"))
}
@Test("Description falls back to basename when empty")
func descriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-D"))
}
@Test("Copyright only when non-empty")
func copyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-C"))
#expect(args.contains("Gronod 2026"))
}
@Test("No -u passed")
func noProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-u"))
}
}
@@ -0,0 +1,24 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ColprofProgress")
struct ColprofProgressTests {
@Test("Classifies gamut mapping")
func gamutMapping() {
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
}
@Test("Classifies fitting or clut")
func fitting() {
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
}
@Test("Classifies writing")
func writing() {
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
}
}
+105
View File
@@ -0,0 +1,105 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("DriftAlert")
struct DriftAlertTests {
@Test("No alert with fewer than two poor results")
func notEnough() {
let records = [
record(avg: 4.0, at: 1000)
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Alert on two poor results one hour apart")
func oneHourApart() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 4600)
]
#expect(DriftAlert.compute(from: records) != nil)
}
@Test("No alert if same day and under one hour")
func sameDayUnderHour() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000)
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Alert on distinct days")
func distinctDays() {
let day1 = record(avg: 4.0, at: 0)
let day2 = record(avg: 5.0, at: 86400 + 1000)
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
}
@Test("Non-poor records do not trigger")
func nonPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 1.5, at: 86400)
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Non-poor records break the consecutive poor run")
func nonPoorBreaksRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good breaks the run
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Only the final consecutive poor run is considered")
func onlySuffixRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 18000), // poor, > 1h from first
record(avg: 1.0, at: 20000), // good breaks the run
record(avg: 4.0, at: 25000), // poor
record(avg: 4.5, at: 26000) // poor, < 1h and same day
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Final consecutive poor run alerts when far apart")
func suffixRunAlerts() {
let records = [
record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor
record(avg: 4.5, at: 4600) // poor, 1h after previous
]
#expect(DriftAlert.compute(from: records) != nil)
}
@Test("A single final poor record after good records does not alert")
func singleFinalPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 4.0, at: 86400)
]
#expect(DriftAlert.compute(from: records) == nil)
}
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
VerificationRecord(
id: "vr-\(Int(offset))",
profileName: "p",
printerName: "",
avgDE: avg,
maxDE: avg,
rmsDE: avg,
patchCount: 1,
status: VerificationStatus.from(avgDE: avg),
timestamp: Date(timeIntervalSince1970: offset)
)
}
}
@@ -0,0 +1,16 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("IccgamutArgs")
struct IccgamutArgsTests {
@Test("Density is 10 and not a directory")
func densityNotDirectory() throws {
let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
)
let args = try IccgamutArgs.build(config: config)
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
}
}
@@ -0,0 +1,296 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("InstrumentParser")
struct InstrumentParserTests {
@Test("Parses pretty-printed instlist JSON")
func json() throws {
let json = """
{
"event": "instruments",
"devices": [
{"port": 1, "name": "X-Rite i1Pro", "type": "usb"},
{"port": 2, "name": "i1Pro 2", "type": "usb"},
{"port": 3, "name": "i1iO Table", "type": "usb"}
]
}
"""
let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3)
#expect(devices[0].port == 1)
#expect(devices[0].name == "X-Rite i1Pro")
#expect(devices[2].port == 3)
}
@Test("Falls back to regex for legacy instlist text")
func regexFallback() throws {
let text = """
1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile'
""" + "\n"
let devices = try InstrumentParser.parse(text)
#expect(devices.count == 2)
#expect(devices[0].port == 1)
#expect(devices[1].name == "ColorMunki Smile")
}
@Test("Empty output returns no devices")
func empty() throws {
#expect(try InstrumentParser.parse("").isEmpty)
}
}
@Suite("ChartreadArgs")
struct ChartreadArgsTests {
@Test("Baseline argv and port 1 omits -c")
func baseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "target"])
}
@Test("Port > 1 emits -c")
func portArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "-c", "3", "target"])
}
@Test("LEDs emit -Y l")
func leds() throws {
let config = ChartreadConfig(
basename: "target",
selectedPort: 2,
enableLEDs: true
)
let args = try ChartreadArgs.build(config: config)
#expect(args.contains("-Y"))
#expect(args.contains("l"))
}
@Test("Auto omits -c")
func autoPort() throws {
let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config)
#expect(!args.contains("-c"))
}
}
@Suite("ChartreadClassifier")
struct ChartreadClassifierTests {
@Test("Calibration prompt")
func calibration() {
let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle
)
#expect(r.state == .calibrating)
}
@Test("Strip awaiting")
func awaitingStrip() {
let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A",
previousState: .calibrating
)
#expect(r.state == .awaitingStrip)
}
@Test("Done prompt")
func done() {
let r = ChartreadClassifier.classify(
line: "'d' if/when done",
previousState: .awaitingStrip
)
#expect(r.state == .allStripsRead)
}
@Test("XY place sheet")
func placeSheet() {
let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table",
previousState: .idle
)
#expect(r.state == .tablePlaceSheet)
#expect(r.sheetNumber == 1)
#expect(r.sheetTotal == 2)
}
@Test("XY locate patch")
func locatePatch() {
let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet
)
#expect(r.state == .tableAlign)
#expect(r.alignmentPatch == "A1")
}
@Test("Remove sheet notice preserves state")
func removeNotice() {
let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table",
previousState: .tablePlaceSheet
)
#expect(r.state == .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true)
}
}
@Suite("ChartreadRow")
struct ChartreadRowTests {
@Test("Decodes row JSON")
func decode() throws {
let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [
{"id": "1", "loc": "A1", "is_pad": false, "device": [0, 50, 100],
"expected": {"Lab": [50, 0, 0]},
"measured": {"Lab": [51, 1, -1]}}
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A")
#expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51)
}
}
@Suite("ColourMath")
struct ColourMathTests {
@Test("White XYZ to Lab")
func whiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white)
#expect(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5)
}
@Test("Lab to sRGB roundtrip is clamped")
func labToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red)
#expect(rgb.r > 0.8)
#expect(rgb.g < 0.2)
#expect(rgb.b < 0.2)
}
@Test("Pad white returns DisplayRGB")
func padWhite() {
let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white)
#expect(rgb.r > 0.9)
#expect(rgb.g > 0.9)
#expect(rgb.b > 0.9)
}
@Test("Standard CIEDE2000 vector (Sharma)")
func ciede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
}
@Test("Classification respects thresholds")
func classify() {
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
}
}
@Suite("MeasurementArtefacts")
struct MeasurementArtefactTests {
private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
@Test("Discovers passes in order")
func discovery() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
try "A".write(to: cwd.appendingPathComponent("target_pass3.ti3"), atomically: true, encoding: .utf8)
try "B".write(to: cwd.appendingPathComponent("target_pass1.ti3"), atomically: true, encoding: .utf8)
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
}
@Test("Snapshot and promote are atomic")
func snapshotPromote() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
let canonical = cwd.appendingPathComponent("target.ti3")
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass.lastPathComponent == "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
#expect(promoted.lastPathComponent == "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path))
}
@Test("Pass collisions handled")
func collision() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
let canonical = cwd.appendingPathComponent("target.ti3")
try "v1".write(to: canonical, atomically: true, encoding: .utf8)
_ = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass2.lastPathComponent == "target_pass2.ti3")
}
}
@Suite("AverageArgs")
struct AverageArgsTests {
@Test("Requires at least two pass files")
func passCount() {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
)
#expect(throws: AverageArgError.self) {
_ = try AverageArgs.build(config: config)
}
}
@Test("Output is last and inputs are relative")
func ordering() throws {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [
URL(fileURLWithPath: "/tmp/target_pass1.ti3"),
URL(fileURLWithPath: "/tmp/target_pass2.ti3"),
]
)
let args = try AverageArgs.build(config: config)
#expect(args.first == "-v")
#expect(args.last == "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
}
}
@@ -0,0 +1,17 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ProfcheckArgs")
struct ProfcheckArgsTests {
@Test("Hard-coded argv")
func argv() throws {
let config = ProfcheckConfig(
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
iccURL: URL(fileURLWithPath: "/tmp/target.icc")
)
let args = try ProfcheckArgs.build(config: config)
#expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
}
}
@@ -0,0 +1,71 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ProfcheckParser")
struct ProfcheckParserTests {
@Test("Prefers JSON report with de2000 keys")
func jsonReport() {
let output = """
No of test patches = 52
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.patchCount == 52)
#expect(report.avgDE == 0.85)
#expect(report.maxDE == 2.41)
#expect(report.rmsDE == 1.02)
#expect(report.status == .excellent)
}
@Test("Falls back to legacy text")
func legacyText() {
let output = """
No of test patches = 120
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.patchCount == 120)
#expect(report.avgDE == 1.80)
#expect(report.maxDE == 3.50)
#expect(report.rmsDE == 0.95)
#expect(report.status == .good)
}
@Test("Broad regex fallback")
func regexFallback() {
let output = """
No of test patches = 10
avg = 4.25
max = 6.10
rms = 2.30
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.avgDE == 4.25)
#expect(report.maxDE == 6.10)
#expect(report.rmsDE == 2.30)
#expect(report.status == .poor)
}
@Test("Unparseable output warns, not zeros")
func unparseable() {
let output = "some random text without metrics"
let report = ProfcheckParser.parse(output)
#expect(report.isValid == false)
#expect(report.warning != nil)
#expect(report.avgDE == nil)
}
@Test("Status bands")
func statusBands() {
#expect(VerificationStatus.from(avgDE: 0.5) == .excellent)
#expect(VerificationStatus.from(avgDE: 1.5) == .good)
#expect(VerificationStatus.from(avgDE: 2.5) == .acceptable)
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
}
}
@@ -0,0 +1,171 @@
import Foundation
import Testing
@testable import ICCeryCore
/// A `FileManager` subclass that reports a temporary directory as the
/// user home, so `ProfileInstaller` can be tested without writing to the
/// real `~/Library/ColorSync/Profiles`.
private final class TestFileManager: FileManager {
let tempHome: URL
init(home: URL) {
self.tempHome = home
super.init()
}
override var homeDirectoryForCurrentUser: URL {
tempHome
}
}
@Suite("ProfileInstaller")
struct ProfileInstallerTests {
private func makeTempDir() throws -> URL {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
return tmp
}
private func makeSource(
at dir: URL,
name: String,
bytes: [UInt8] = Array(repeating: 0, count: 256)
) throws -> URL {
let url = dir.appendingPathComponent(name)
let data = Data(bytes)
try data.write(to: url)
return url
}
@Test("Installs .icc to user ColorSync folder")
func userInstall() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "test.icc")
let result = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#expect(result.registered)
#expect(!result.overwritten)
#expect(!result.renamed)
#expect(result.destPath.hasSuffix("test.icc"))
#expect(fm.fileExists(atPath: result.destPath))
}
@Test("Overwrite succeeds and replaces the existing file")
func overwriteSucceeds() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icc", bytes: (0..<256).map { UInt8($0) })
// First install.
let first = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#expect(!first.overwritten)
// Change the source contents.
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
try Data(newBytes).write(to: source)
let options = InstallProfileOptions(
forceOverwrite: true,
preferSystem: false,
collisionPolicy: .overwrite,
openColorPanel: false
)
let second = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source, options: options),
fileManager: testFM
)
#expect(second.overwritten)
#expect(!second.renamed)
#expect(fm.fileExists(atPath: second.destPath))
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
#expect(Array(installed) == newBytes)
}
@Test("Preserves .icm source extension")
func preservesIcmExtension() throws {
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icm")
let result = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm")
#expect(result.destPath.hasSuffix("m5_profile.icm"))
}
@Test("Rejects parent traversal in source path")
func rejectsParentTraversal() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
// Create a real file in the parent of `tmp` with a path that contains
// a literal ".." component.
let parent = tmp.deletingLastPathComponent()
let naughtyName = "naughty-\(UUID().uuidString).icc"
let realFile = parent.appendingPathComponent(naughtyName)
_ = try makeSource(at: parent, name: naughtyName)
defer { try? fm.removeItem(at: realFile) }
let sourceURL = tmp
.appendingPathComponent("..")
.appendingPathComponent(naughtyName)
#expect(fm.fileExists(atPath: sourceURL.path))
do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
Issue.record("Expected unsafeStem error")
} catch let error as ProfileInstallError {
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
} catch {
Issue.record("Unexpected error type: \(error)")
}
}
@Test("Allows stems with consecutive dots like foo..bar")
func allowsDoubleDotStem() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "foo..bar.icc")
let result = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#expect(result.destPath.hasSuffix("foo..bar.icc"))
#expect(fm.fileExists(atPath: result.destPath))
}
@Test("Rejects source files that are too small")
func rejectsSmallSource() throws {
let tmp = try makeTempDir()
let source = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: source)
do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
Issue.record("Expected sourceTooSmall error")
} catch let error as ProfileInstallError {
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
} catch {
Issue.record("Unexpected error type: \(error)")
}
}
}
@@ -0,0 +1,159 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("VerificationHistoryStore")
struct VerificationHistoryStoreTests {
@Test("Append and cap")
func appendAndCap() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
let store = VerificationHistoryStore(url: url, capacity: 3)
for i in 0..<5 {
let record = VerificationRecord(
id: "vr-\(i)",
profileName: "p",
printerName: "",
avgDE: Double(i),
maxDE: Double(i),
rmsDE: Double(i),
patchCount: i,
status: .good,
timestamp: Date(timeIntervalSince1970: TimeInterval(i))
)
_ = try await store.append(record)
}
let all = await store.all()
#expect(all.count == 3)
#expect(all.first?.avgDE == 2.0)
}
@Test("Parse failure preserves file")
func parseFailurePreservesFile() async {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
try? "not json".write(to: url, atomically: true, encoding: .utf8)
let store = VerificationHistoryStore(url: url)
do {
_ = try await store.load()
Issue.record("load() should throw on invalid JSON")
} catch {
#expect(fm.fileExists(atPath: url.path))
}
}
@Test("Append loads existing records first")
func appendLoadsExisting() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
// Pre-populate the store on disk.
let existing = VerificationRecord(
id: "vr-existing",
profileName: "p",
printerName: "",
avgDE: 1.0,
maxDE: 1.0,
rmsDE: 1.0,
patchCount: 1,
status: .good,
timestamp: Date(timeIntervalSince1970: 0)
)
let store1 = VerificationHistoryStore(url: url)
_ = try await store1.append(existing)
// A fresh store appending a new record must keep the existing one.
let store2 = VerificationHistoryStore(url: url)
let new = VerificationRecord(
id: "vr-new",
profileName: "p",
printerName: "",
avgDE: 2.0,
maxDE: 2.0,
rmsDE: 2.0,
patchCount: 2,
status: .good,
timestamp: Date(timeIntervalSince1970: 10)
)
_ = try await store2.append(new)
let all = await store2.all()
#expect(all.count == 2)
#expect(all.contains { $0.id == "vr-existing" })
#expect(all.contains { $0.id == "vr-new" })
}
@Test("Append does not overwrite an unparseable file")
func appendPreservesUnparseableFile() async {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
let badJSON = "not json"
try? badJSON.write(to: url, atomically: true, encoding: .utf8)
let store = VerificationHistoryStore(url: url)
let record = VerificationRecord(
id: "vr-new",
profileName: "p",
printerName: "",
avgDE: 1.0,
maxDE: 1.0,
rmsDE: 1.0,
patchCount: 1,
status: .good,
timestamp: Date(timeIntervalSince1970: 0)
)
do {
_ = try await store.append(record)
Issue.record("append() should propagate the load error")
} catch {
#expect(fm.fileExists(atPath: url.path))
if let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8) {
#expect(contents == badJSON)
} else {
Issue.record("Could not read preserved file")
}
}
}
@Test("CSV export quoting")
func csvQuoting() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
let store = VerificationHistoryStore(url: url)
let record = VerificationRecord(
id: "a,b",
profileName: "\"quoted\"",
printerName: "",
avgDE: 1.0,
maxDE: 2.0,
rmsDE: 3.0,
patchCount: 1,
status: .good,
timestamp: Date(timeIntervalSince1970: 0)
)
_ = try await store.append(record)
let csv = await store.exportCSV()
#expect(csv.contains("\"a,b\""))
#expect(csv.contains("\"\"quoted\"\""))
}
}
@@ -0,0 +1,72 @@
import XCTest
/// About and help chrome UI tests (issue #31).
@MainActor
final class AboutHelpUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() async throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchEnvironment = ["ICCERY_UI_TESTING": "1"]
}
override func tearDown() async throws {
app?.terminate()
app = nil
}
private func element(_ id: String) -> XCUIElement {
app.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
func testAboutDialogShowsVersionAndBuildDate() throws {
app.launch()
app.activate()
let openAbout = app.buttons["openAboutBtn"]
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
openAbout.click()
_ = waitFor("aboutDialog", timeout: 10)
XCTAssertTrue(element("aboutVersion").exists)
XCTAssertTrue(element("aboutBuildDate").exists)
let close = app.buttons["closeAboutBtn"]
XCTAssertTrue(close.exists)
close.click()
XCTAssertFalse(element("aboutDialog").exists)
}
func testHelpOverlaysDoNotChangeSidebarHeight() throws {
app.launch()
app.activate()
let toggle = app.buttons["btnToggleAllHelp"]
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element
let before = sidebar.frame
toggle.click()
let after = sidebar.frame
XCTAssertEqual(before.size.height, after.size.height,
"Toggling global help must not reflow the sidebar height.")
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Mock applycal for Milestone 5 UI tests.
# Copies the input profile to the optional output path.
while [ "$#" -gt 0 ]; do
case "$1" in
-v|-a|-u) shift ;;
*) break ;;
esac
done
cal="$1"
input="$2"
output="$3"
if [ -n "$output" ]; then
cp "$input" "$output"
else
cp "$cal" "$input.cal.ctl"
fi
exit 0
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Mock average for Milestone4UITests.
# Usage: average -v pass1.ti3 pass2.ti3 ... output.ti3
# The canonical output is the last argument.
# Set MOCK_AVERAGE_FAIL=1 to exit with code 1.
if [ "${MOCK_AVERAGE_FAIL:-0}" -ne 0 ]; then
echo "average: could not converge" >&2
exit 1
fi
# Drop leading -v
shift
output="$1"
if [ $# -ge 2 ]; then
output="$2"
fi
# Find last argument
for arg in "$@"; do
output="$arg"
done
# Sanity: the output is the last argument.
# Write a fake canonical .ti3 that identifies the inputs.
{
echo "CTI3"
echo "INPUTS:"
for arg in "$@"; do
if [ "$arg" != "$output" ]; then
echo "$arg"
fi
done
} > "$output"
exit 0
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Mock chartread for Milestone4UITests.
Supports handheld (MOCK_CHARTREAD_MODE=strip) and XY (MOCK_CHARTREAD_MODE=xy).
Writes basename.ti3 on receiving 'd' and exits 0.
Exit 0 and no .ti3 on 'q' before done.
Usage: chartread -v -u [-c port] [-Y l] basename
"""
import json
import os
import sys
def read_line():
try:
return sys.stdin.readline()
except Exception:
return ""
def emit_row(payload: dict):
text = "ROW_COLORS_JSON: " + json.dumps(payload)
print(text, flush=True)
def write_ti3(basename: str):
if basename:
with open(f"{basename}.ti3", "w") as f:
f.write("MOCK_TI3\n")
def main():
mode = os.environ.get("MOCK_CHARTREAD_MODE", "strip")
basename = ""
for arg in sys.argv[1:]:
if arg.startswith("-"):
continue
basename = arg
def read_input():
line = read_line()
if line == "":
sys.exit(1)
return line.strip()
if mode == "xy":
print("Place instrument on calibration tile and hit [Space] to calibrate.", flush=True)
read_input()
print("Calibration successful.", flush=True)
print("Please place sheet 1 of 1 on the table", flush=True)
print("hit return to continue, Esc or 'q' to give up", flush=True)
read_input()
print("locate patch A1 with the sight,", flush=True)
print("then hit return to continue", flush=True)
read_input()
print("Reading sheet 1...", flush=True)
emit_row({
"event": "row_complete",
"row_id": "A",
"row_index": 0,
"total_rows": 1,
"patch_count": 3,
"patches": [
{"id": "1", "loc": "A1", "is_pad": False, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}},
{"id": "2", "loc": "A2", "is_pad": False, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}},
{"id": "3", "loc": "A3", "is_pad": True, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}},
]
})
print("Sheet 1 of 1 read OK", flush=True)
print("Please remove last sheet from table", flush=True)
print("'d' if/when done", flush=True)
while True:
line = read_input()
if line.startswith("d"):
write_ti3(basename)
sys.exit(0)
if line.startswith("q"):
sys.exit(0)
# Handheld / strip mode (default)
print("Place instrument on calibration tile and hit [Space] to calibrate.", flush=True)
read_input()
print("Calibration successful.", flush=True)
print("Hit [Space] to read strip A", flush=True)
read_input()
print("Reading strip A...", flush=True)
emit_row({
"event": "row_complete",
"row_id": "A",
"row_index": 0,
"total_rows": 2,
"patch_count": 3,
"patches": [
{"id": "1", "loc": "A1", "is_pad": False, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}},
{"id": "2", "loc": "A2", "is_pad": False, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}},
{"id": "3", "loc": "A3", "is_pad": True, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}},
]
})
print("Hit [Space] to read strip B", flush=True)
read_input()
print("Reading strip B...", flush=True)
emit_row({
"event": "row_complete",
"row_id": "B",
"row_index": 1,
"total_rows": 2,
"patch_count": 2,
"patches": [
{"id": "4", "loc": "B1", "is_pad": False, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}},
{"id": "5", "loc": "B2", "is_pad": False, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}},
]
})
print("'d' if/when done", flush=True)
while True:
line = read_input()
if line.startswith("d"):
write_ti3(basename)
sys.exit(0)
if line.startswith("q"):
sys.exit(0)
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Mock colprof for Milestone 5 UI tests.
# Writes {basename}.icc next to the last argument and emits progress.
last=""
for arg in "$@"; do last="$arg"; done
if [ "${ICCERY_MOCK_COLPROF_EXIT:-0}" -ne 0 ]; then
echo "mock colprof failure" >&2
exit "$ICCERY_MOCK_COLPROF_EXIT"
fi
echo "Gamut mapping calculation..."
echo "Fitting cLUT grid points..."
echo "Writing ICC profile..."
touch "$last.icc"
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Mock iccgamut for Milestone 5 UI tests.
# Writes {stem}.gam next to the profile path.
last=""
for arg in "$@"; do last="$arg"; done
if [ "${ICCERY_MOCK_ICCGAMUT_EXIT:-0}" -ne 0 ]; then
echo "mock iccgamut failure" >&2
exit "$ICCERY_MOCK_ICCGAMUT_EXIT"
fi
stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//')
dir=$(dirname "$last")
touch "$dir/$stem.gam"
exit 0
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Mock instlist for Milestone4UITests. Emits a pretty JSON device list.
# Override the list with ICCERY_MOCK_INSTLIST_JSON.
if [ -n "${ICCERY_MOCK_INSTLIST_JSON}" ]; then
echo "${ICCERY_MOCK_INSTLIST_JSON}"
exit 0
fi
printf '{
"event": "instruments",
"devices": [
{"port": 1, "name": "X-Rite i1Pro", "type": "usb"},
{"port": 2, "name": "X-Rite i1Pro 2", "type": "usb"},
{"port": 3, "name": "i1iO Table", "type": "usb"}
]
}
'
exit 0
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Mock profcheck for Milestone 5 UI tests.
# Emits a JSON report and legacy text summary.
if [ "${ICCERY_MOCK_PROFCHECK_EXIT:-0}" -ne 0 ]; then
echo "mock profcheck failure" >&2
exit "$ICCERY_MOCK_PROFCHECK_EXIT"
fi
echo "No of test patches = 52"
sleep 0.1
printf '{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}\n'
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
exit 0
+4 -3
View File
@@ -164,10 +164,11 @@ final class Milestone2UITests: XCTestCase {
XCTAssertTrue(FileManager.default.fileExists(
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
// M3 stubs: visible but inert.
// Print panel is live from M3; a default printer is selected
// so both the all-pages and per-page print buttons are enabled.
XCTAssertTrue(element("rawPrintPanel").exists)
XCTAssertFalse(app.buttons["btnPrintAll"].isEnabled)
XCTAssertFalse(app.buttons["btnPrintPage-0"].isEnabled)
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
XCTAssertTrue(app.buttons["btnPrintPage-0"].isEnabled)
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
}
+30 -12
View File
@@ -49,6 +49,23 @@ final class Milestone3UITests: XCTestCase {
testRoot = nil
}
private func waitForFileContent(
_ url: URL,
containing needle: String,
timeout: TimeInterval = 10
) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8),
text.contains(needle) {
return text
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
}
private func launchApp() {
app.launch()
app.activate()
@@ -171,6 +188,14 @@ final class Milestone3UITests: XCTestCase {
reachPrintPanel()
_ = waitFor("printerStatusBadge")
// Wait for the async printer enumeration to select a queue; once
// `btnPrintAll` is enabled, `btnPrintPage-0` is too.
let deadline = Date().addingTimeInterval(15)
while Date() < deadline, !app.buttons["btnPrintAll"].isEnabled {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
app.buttons["btnPrintPage-0"].click()
let argv = waitForLpLine()
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
@@ -202,18 +227,11 @@ final class Milestone3UITests: XCTestCase {
let stateURL = testRoot
.appendingPathComponent("AppData")
.appendingPathComponent("wizard_state.json")
XCTAssertTrue(waitForFile(stateURL))
let data = try Data(contentsOf: stateURL)
let state = String(data: data, encoding: .utf8) ?? ""
XCTAssertTrue(state.contains("Mock_Epson_7450"), state)
let state = waitForFileContent(
stateURL, containing: "Mock_Epson_7450", timeout: 15)
XCTAssertNotNil(state)
XCTAssertTrue((state ?? "").contains("Mock_Epson_7450"))
}
private func waitForFile(_ url: URL, timeout: TimeInterval = 10) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return false
}
}
+143
View File
@@ -0,0 +1,143 @@
import XCTest
/// Milestone 4 UI tests issues #18#22.
/// Uses the same isolated-fixture strategy as M2/M3.
@MainActor
final class Milestone4UITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_TEST_SAVE_TARGET":
workDir.appendingPathComponent("mytarget.ti1").path,
"ICCERY_TEST_WORKDIR": workDir.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
private func launchApp() {
app.launch()
app.activate()
}
private func element(_ id: String) -> XCUIElement {
app.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
/// Reach Stage 3 by generating a target, creating a layout, and
/// advancing from Stage 2.
private func reachStage3() {
launchApp()
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
_ = waitFor("btnCreateLayout", timeout: 20)
app.buttons["btnCreateLayout"].click()
_ = waitFor("galleryPage-0", timeout: 20)
_ = waitFor("btnAdvanceToStage3", timeout: 10)
app.buttons["btnAdvanceToStage3"].click()
_ = waitFor("stage3TargetBasename", timeout: 10)
}
/// Fixture-driven instrument detection populates the picker.
func testInstrumentDetectionPopulatesPicker() throws {
reachStage3()
app.buttons["btnDetectInstruments"].click()
XCTAssertTrue(waitFor("chartreadInstrumentSelect", timeout: 20).exists)
let picker = app.popUpButtons["chartreadInstrumentSelect"]
XCTAssertTrue(picker.waitForExistence(timeout: 5))
picker.click()
// The fixture provides three devices plus the default Auto entry.
XCTAssertTrue(app.menuItems.count >= 3)
}
/// End-to-end handheld chartread with the mock fixture produces a
/// canonical .ti3 and unlocks Stage 4.
func testHandheldFixtureChartreadAndAverage() throws {
reachStage3()
app.buttons["btnDetectInstruments"].click()
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
// Keep Auto (port 1) and start the session.
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
app.buttons["btnStartRead"].click()
// Calibrate.
let calibrate = element("btnCalibrate")
if !calibrate.waitForExistence(timeout: 25) {
let error = element("chartreadLastError").label
let value = element("chartreadLastError").value as? String ?? "<nil>"
XCTFail("No calibrate button. lastError.label='\(error)' value='\(value)'")
}
app.buttons["btnCalibrate"].click()
// Trigger strip A.
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
// Trigger strip B.
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
// All strips read Done & Save appears.
_ = waitFor("btnDoneRead", timeout: 20)
app.buttons["btnDoneRead"].firstMatch.click()
// Averaging panel appears with one pass snapshot.
_ = waitFor("chartreadAveragingPanel", timeout: 20)
_ = waitFor("passCounterBadge", timeout: 20)
XCTAssertTrue(app.buttons["btnFinishAndAverage"].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["btnFinishAndAverage"].isEnabled)
app.buttons["btnFinishAndAverage"].click()
// Finish promotion should create the canonical .ti3 and
// advance the wizard to Stage 4.
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))
}
}
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import XCTest
/// Milestone 5 UI tests issues #23#27.
@MainActor
final class Milestone5UITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
private var appDataDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui-m5-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
appDataDir = testRoot.appendingPathComponent("AppData")
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: appDataDir, withIntermediateDirectories: true)
// Pre-stage a measured .ti3 so the wizard is already on Stage 4.
FileManager.default.createFile(
atPath: workDir.appendingPathComponent("mytarget.ti3").path,
contents: Data("MOCK_TI3".utf8),
attributes: nil)
let state: [String: Any] = [
"currentStage": 4,
"basename": "mytarget",
"cwd": workDir.path,
"printerName": "MockPrinter",
"sessionMode": "profile"
]
let stateData = try JSONSerialization.data(withJSONObject: state, options: [])
try stateData.write(to: appDataDir.appendingPathComponent("wizard_state.json"))
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_TEST_WORKDIR": workDir.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
private func launchApp() {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
}
}
private func element(_ id: String) -> XCUIElement {
app.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 20) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
/// Mock colprof produces a profile, unlocks Stage 5, and the
/// mock profcheck reports Good.
func testBuildProfileAndVerify() throws {
launchApp()
let create = waitFor("btnCreateProfile")
XCTAssertTrue(create.isEnabled)
create.click()
_ = waitFor("btnVerifyProfile", timeout: 30)
// The mock iccgamut should have written a .gam next to the profile.
let gam = workDir.appendingPathComponent("mytarget.gam")
let icc = workDir.appendingPathComponent("mytarget.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: icc.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: gam.path))
app.buttons["btnVerifyProfile"].click()
_ = waitFor("profcheckStatus", timeout: 30)
let statusValue = app.staticTexts["profcheckStatus"].firstMatch.value as? String ?? ""
XCTAssertTrue(
statusValue.contains("Good") || statusValue.contains("Excellent"),
"Expected verification status, got '\(statusValue)'"
)
}
}
@@ -0,0 +1,64 @@
import Foundation
import XCTest
/// Milestone 6 CGATS import UI tests (issue #30).
@MainActor
final class Milestone6CGATSUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var datasetURL: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-cgats-ui-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
datasetURL = testRoot.appendingPathComponent("imported.csv")
try csv.write(to: datasetURL, atomically: true, encoding: .utf8)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_WORKDIR": testRoot.path,
"ICCERY_TEST_DATASET_IMPORT": datasetURL.path
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
}
/// `Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1`
/// Must fail if import presents a save panel or a `.ti1` filter.
func testImportUsesOpenPanelNotSaveTi1() throws {
app.launch()
app.activate()
XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10))
app.buttons["btn-import-dataset"].click()
// No save panel should appear; the open panel is stubbed under UI testing.
let savePanel = app.sheets.firstMatch
XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.")
// The dataset should be accepted and the user should advance to Stage 4.
_ = app.otherElements["stage-4"].waitForExistence(timeout: 10)
XCTAssertTrue(app.otherElements["stage-4"].exists)
// The canonical .ti3 should be written next to the source file.
let ti3URL = testRoot.appendingPathComponent("imported.ti3")
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3URL.path))
}
}