diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift index fbbfb9e..df8b192 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -742,6 +742,148 @@ public struct ArgyllRunner: Sendable { } } + // MARK: - spotread (spot-read console, issue #148) + + /// Runs `spotread` and returns an `AsyncStream` of typed events. + /// + /// Same subscribe-before-spawn shape as `runChartread`, but there is + /// no artefact: the stream ends with `.exit(code)`. The single-lease + /// process id is `ProcessID.spotread` — never `chartread_{basename}`. + /// Missing sidecar surfaces as `.failed`; there is no `$PATH` or + /// `chartread` fallback (#116, R14/R21). + public func runSpotread(config: SpotReadConfig) -> AsyncStream { + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = SpotReadArgs.build(config: config) + let binaryURL = binaryResolver.resolve("spotread") + let processId = ProcessID.spotread + let processManager = self.processManager + let isXY = config.isXY + let instrumentName = config.instrumentName + let instrumentPort = config.instrumentPort + + return AsyncStream { continuation in + let task = Task { + await ensureNotRunning(id: processId) + let events = processManager.events() + + // XY parking hook before any kill, same as chartread. + await processManager.setPreKillHook(id: processId) { [processManager] in + if isXY { + try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) + try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000)) + } + } + + guard binaryResolver.exists(binaryURL) else { + continuation.yield(.failed(ArgyllRunnerError.toolFailed( + tool: "spotread", code: -1, + logs: ["spotread sidecar missing — run fetch-argyll"]))) + continuation.finish() + return + } + + do { + try await processManager.runStreaming( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + } catch { + continuation.yield(.failed(ArgyllRunnerError.toolFailed( + tool: "spotread", code: -1, logs: [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): + if let parsed = SpotReadParser.parse(line: line) { + continuation.yield(.sample(SpotReadSample( + lab: parsed.lab, + xyz: parsed.xyz, + instrumentName: instrumentName, + port: instrumentPort, + rawLine: line + ))) + } + let classified = SpotReadClassifier.classify( + line: line, previousState: state) + if classified.state != state + || classified.requestedWarningKey != nil { + state = classified.state + continuation.yield(.prompt(classified)) + } + pendingLogs.append(line) + + case .stderr(_, let line): + pendingLogs.append(line) + + case .jsonRow: + // spotread is never run with `-u`. + break + + 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 + } + } + + continuation.yield(.exit(exitCode ?? -1)) + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + Task { + await processManager.kill(id: processId) + } + } + } + } + + /// Send input bytes to the running `spotread` child. Reuses + /// `ChartreadInput` — the stdin protocol is identical. + public func sendSpotreadInput(_ input: ChartreadInput) async throws { + try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes) + } + + /// Terminate a running `spotread` child. The XY park (`q\n` + + /// ~500 ms) runs in the pre-kill hook registered by `runSpotread`. + public func cancelSpotread() { + Task { + await processManager.kill(id: ProcessID.spotread) + } + } + // MARK: - Stage 0 calibration /// Generates a calibration wedge `.ti1`. @@ -828,6 +970,20 @@ public enum ChartreadEvent: Sendable { case failed(ArgyllRunnerError) } +/// Events emitted by a running `spotread` session (issue #148). +public enum SpotReadEvent: Sendable { + /// Classified prompt / state update (reuses `ChartreadState`). + case prompt(ChartreadClassifyResult) + /// A parsed `Result is …` sample line. + case sample(SpotReadSample) + /// A batched log chunk (stdout + stderr lines). + case log([String]) + /// Process exited with the given code. + case exit(Int32) + /// Failure (missing sidecar, spawn error). + case failed(ArgyllRunnerError) +} + /// Exact bytes sent to `chartread` stdin. public enum ChartreadInput: Sendable { case trigger // " \n" diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift new file mode 100644 index 0000000..f454672 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Pure argv builder for Argyll's `spotread` tool (issue #148). +public enum SpotReadArgs { + + /// Builds `spotread` argv per the Gronod fork protocol. + /// + /// - Always `-v -e` (paper / reflective; never display `-d`). + /// - `-c N` is emitted only for `selectedPort != nil` and `N > 1` + /// (Auto and port 1 omit it, #111). + /// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204). + /// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread + + /// profcheck only. + /// - No basename — `spotread` writes no artefact. + public static func build(config: SpotReadConfig) -> [String] { + var args: [String] = ["-v", "-e"] + + if let port = config.selectedPort, port > 1 { + args.append(contentsOf: ["-c", "\(port)"]) + } + + if config.enableLEDs { + args.append(contentsOf: ["-Y", "l"]) + } + + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift new file mode 100644 index 0000000..2d815e2 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Configuration for a `spotread` invocation (issue #148). +/// +/// `spotread` writes no artefact; `workingDirectory` is still required +/// for the spawn (#59 — empty cwd is illegal). +public struct SpotReadConfig: Codable, Equatable, Sendable { + public var workingDirectory: URL? + /// Communication port for `spotread -c`. + /// `nil` means omit `-c` (Auto or port 1, #111). Never an array index. + public var selectedPort: Int? + /// Enable i1Pro 2 visual LEDs (`-Y l`, #204). + public var enableLEDs: Bool + /// Whether the selected instrument is an XY table — controls the + /// `q\n` + ~500 ms park before kill on cancel. + public var isXY: Bool + /// Display name stamped onto each `SpotReadSample`. + public var instrumentName: String + /// Instrument port stamped onto each sample (nil for Auto). + public var instrumentPort: Int? + + public init( + workingDirectory: URL? = nil, + selectedPort: Int? = nil, + enableLEDs: Bool = false, + isXY: Bool = false, + instrumentName: String = "", + instrumentPort: Int? = nil + ) { + self.workingDirectory = workingDirectory + self.selectedPort = selectedPort + self.enableLEDs = enableLEDs + self.isXY = isXY + self.instrumentName = instrumentName + self.instrumentPort = instrumentPort + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift new file mode 100644 index 0000000..8a99e02 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Line classifier for `spotread` stdout (issue #148). +/// +/// `spotread` shares `chartread`'s white-tile calibration phrasing, so +/// this wraps `ChartreadClassifier` and only intercepts the lines that +/// would otherwise misclassify: +/// +/// - `… and then hit any key to continue,` / `or hit Esc or Q to abort:` +/// continuation lines that trail the calibration and spot prompts — +/// sticky to the current prompt state instead of `PROMPT_CONTINUE`. +/// - `Place instrument on a spot to be measured,` / +/// `and hit a key to take a reading,` → `AWAITING_STRIP` (the Read +/// prompt; the generic chartread matcher does not know "take a +/// reading"). +/// +/// Sample lines (`Result is XYZ: …, D50 Lab: …`) are parsed by +/// `SpotReadParser`, not classified here. +public enum SpotReadClassifier { + + public static func classify( + line: String, + previousState: ChartreadState + ) -> ChartreadClassifyResult { + let text = line.lowercased() + + // Spot-read prompt continuations keep the current prompt state. + if previousState == .calibrating || previousState == .awaitingStrip { + if text.contains("hit any key") + || text.contains("hit space") + || text.contains("esc or") + || text.contains("abort") + || text.contains("to abort") { + return ChartreadClassifyResult(state: previousState) + } + } + + // "Place instrument on a spot to be measured," / + // " and hit a key to take a reading," — the Read trigger prompt. + if text.contains("spot to be measured") + || text.contains("take a reading") + || text.contains("measure the spot") { + return ChartreadClassifyResult(state: .awaitingStrip) + } + + return ChartreadClassifier.classify(line: line, previousState: previousState) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift new file mode 100644 index 0000000..836221c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift @@ -0,0 +1,80 @@ +import Foundation + +/// One patch measurement emitted by a running `spotread` child (#148). +public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable { + public var id: UUID + public var timestamp: Date + /// D50 Lab (always present — derived from XYZ when needed). + public var lab: LabColor + /// XYZ on the 0–100 scale used by the fork, when the line carried it. + public var xyz: XYZColor? + public var instrumentName: String + public var port: Int? + /// Diagnostics only — never rendered as HTML or shown in the table. + public var rawLine: String + + public init( + id: UUID = UUID(), + timestamp: Date = Date(), + lab: LabColor, + xyz: XYZColor? = nil, + instrumentName: String = "", + port: Int? = nil, + rawLine: String = "" + ) { + self.id = id + self.timestamp = timestamp + self.lab = lab + self.xyz = xyz + self.instrumentName = instrumentName + self.port = port + self.rawLine = rawLine + } +} + +/// Parses `spotread` result lines into Lab / XYZ triples. +/// +/// The fork's line shape is the upstream +/// `Result is XYZ: , D50 Lab: `; a Lab-only line +/// also parses, and an XYZ-only line derives Lab via +/// `LabColorMath.xyzToLab` (D50). +public enum SpotReadParser { + + public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? { + guard line.range(of: "result is", options: .caseInsensitive) != nil + || line.range(of: #"\bLab\b"#, options: .regularExpression) != nil + || line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil + else { return nil } + + var xyz: XYZColor? + var lab: LabColor? + + if let m = triple(#"\bXYZ\b[:\s]"#, in: line) { + xyz = XYZColor(x: m.0, y: m.1, z: m.2) + } + if let m = triple(#"\bLab\b[:\s]"#, in: line) { + lab = LabColor(l: m.0, a: m.1, b: m.2) + } + if lab == nil, let xyz { + lab = LabColorMath.xyzToLab(xyz) + } + guard let lab else { return nil } + return (xyz, lab) + } + + private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? { + let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch( + in: line, options: [], range: NSRange(line.startIndex..., in: line)), + match.numberOfRanges == 4, + let r1 = Range(match.range(at: 1), in: line), + let r2 = Range(match.range(at: 2), in: line), + let r3 = Range(match.range(at: 3), in: line), + let a = Double(line[r1]), + let b = Double(line[r2]), + let c = Double(line[r3]) + else { return nil } + return (a, b, c) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift index 80d6242..8c2d562 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -4,6 +4,8 @@ import Foundation /// filter events on `id` — historical bug #56 was an id mismatch. public enum ProcessID { public static let instlist = "instlist" + /// Spot-read console (issue #148) — single lease, like `instlist`. + public static let spotread = "spotread" public static func targen(_ basename: String) -> String { "targen_\(basename)" } public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" } diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index 0576f8d..5dcfb55 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -21,11 +21,15 @@ struct AppEnvironment: Sendable { let settingsStore = SettingsStore() var overrideDir = settingsStore.load().argyllBinaryDir .map { URL(fileURLWithPath: $0) } + var bundledRoot = AppPaths.bundledArgyllDir var cupsDir = URL(fileURLWithPath: "/usr/bin") #if DEBUG if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty { overrideDir = URL(fileURLWithPath: dir) } + if let dir = environment["ICCERY_ARGYLL_BUNDLED_ROOT"], !dir.isEmpty { + bundledRoot = URL(fileURLWithPath: dir) + } if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty { cupsDir = URL(fileURLWithPath: dir) } @@ -36,7 +40,8 @@ struct AppEnvironment: Sendable { presetStore: PresetStore(settingsStore: settingsStore), runner: ArgyllRunner( processManager: .shared, - binaryResolver: BinaryResolver(overrideDir: overrideDir) + binaryResolver: BinaryResolver( + bundledRoot: bundledRoot, overrideDir: overrideDir) ), cupsService: CupsService( processManager: .shared, @@ -76,6 +81,8 @@ enum UITestHooks { static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } /// Preset export destination. static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") } + /// Spot-read CSV export destination (`selectCsvSavePath`, #148). + static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") } // MARK: - Print panel / CUPS stubs (issue 13/17) diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 031fb9b..5322e1b 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -67,6 +67,14 @@ struct RootView: View { ) { ManageMediaDialog(workflow: workflow) } + // Spot-read console sheet (issue #148). Dismiss runs the same + // `q\n` + ~500 ms + kill path as the sheet's Stop button. + .sheet( + isPresented: $workflow.showingSpotRead, + onDismiss: { workflow.spotRead.sheetClosed() } + ) { + SpotReadView(model: workflow.spotRead) + } .sheet(isPresented: $showingAbout) { AboutView { showingAbout = false } } diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift index f55a0b7..6625f68 100644 --- a/Sources/ICCery/SettingsView.swift +++ b/Sources/ICCery/SettingsView.swift @@ -58,7 +58,7 @@ struct SettingsView: View { Text($0.label).tag($0.code) } } - Text("Display-only — Stage 2's instrument select is used for actual runs.") + Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.") .font(.caption) .foregroundStyle(.secondary) diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 47fb1eb..459972b 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -11,6 +11,7 @@ struct SidebarView: View { @ObservedObject private var profile: ProfileWorkflowViewModel @ObservedObject private var media: MediaLibraryViewModel @ObservedObject private var printSession: PrintSessionViewModel + @ObservedObject private var measurement: MeasurementWorkflowViewModel var onOpenSettings: () -> Void var onOpenAbout: () -> Void @Binding var showingAllHelp: Bool @@ -26,6 +27,7 @@ struct SidebarView: View { self._profile = ObservedObject(wrappedValue: workflow.profile) self._media = ObservedObject(wrappedValue: workflow.media) self._printSession = ObservedObject(wrappedValue: workflow.print) + self._measurement = ObservedObject(wrappedValue: workflow.measurement) self.onOpenSettings = onOpenSettings self.onOpenAbout = onOpenAbout self._showingAllHelp = showingAllHelp @@ -161,6 +163,26 @@ struct SidebarView: View { .accessibilityIdentifier("btnViewGamut") .padding(.horizontal, 12) + // Spot Read sheet (`#btnSpotRead`) — issue #148. Enabled + // only with a working folder (#59) and while no Stage 3 + // chartread child is live; opening never kills + // `chartread_{basename}`. + Button(action: { workflow.showingSpotRead = true }) { + Label("Spot Read", systemImage: "eyedropper") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .disabled(model.workingDirectory == nil || measurement.isChartreadRunning) + .helpOverlay( + model.workingDirectory == nil + ? "Set a working folder in Stage 1 first." + : (measurement.isChartreadRunning + ? "Stop the Stage 3 chart read first." + : "Read a single patch as Lab/XYZ from the instrument."), + showing: $showingAllHelp) + .accessibilityIdentifier("btnSpotRead") + .padding(.horizontal, 12) + Divider().overlay(Theme.border) .padding(.vertical, 8) diff --git a/Sources/ICCery/SpotReadView.swift b/Sources/ICCery/SpotReadView.swift new file mode 100644 index 0000000..9d5aea1 --- /dev/null +++ b/Sources/ICCery/SpotReadView.swift @@ -0,0 +1,360 @@ +import SwiftUI +import ICCeryCore + +/// Spot Read sheet (issue #148) — one patch Lab/XYZ from the live +/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3 +/// tab; all identifiers are `spot*` — Stage 3 `chartread` ids are never +/// reused here. +struct SpotReadView: View { + @ObservedObject var model: SpotReadViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + if !model.sidecarAvailable { + missingSidecar + } else { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + instrumentCard + promptLine + transport + lastSampleCard + historySection + } + } + } + footer + } + .padding(16) + .frame(width: 560, height: 640) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotReadView") + .onAppear { model.sheetOpened() } + .onDisappear { model.sheetClosed() } + } + + // MARK: - Header / missing sidecar + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + Text("Spot Read") + .font(.title3) + .foregroundStyle(Theme.text) + Spacer() + if model.isRunning { + ProgressView() + .scaleEffect(0.8) + } + } + } + + private var missingSidecar: some View { + VStack(alignment: .leading, spacing: 12) { + Text("spotread sidecar missing — run fetch-argyll") + .foregroundStyle(Theme.text) + .accessibilityIdentifier("spotSidecarMissing") + Spacer() + } + } + + // MARK: - Instrument card (clones Stage 3 look, own ids) + + private var instrumentCard: 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("btnSpotDetectInstruments") + } + + if let error = model.detectionError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .accessibilityIdentifier("spotDetectError") + } + + 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) + .disabled(model.isRunning) + .accessibilityIdentifier("spotInstrumentSelect") + + if model.defaultMissing { + Text("Saved default instrument not present") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("spotDefaultMissing") + } + + Toggle("Also set as default instrument", isOn: Binding( + get: { model.setAsDefault }, + set: { model.applyDefaultToggle($0) } + )) + .accessibilityIdentifier("spotSetDefault") + + if model.selectedInstrument.isXY { + Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.") + .font(.caption) + .foregroundStyle(Theme.accent) + .accessibilityIdentifier("spotXYHint") + } + } + .padding(16) + .background(Theme.panel) + } + + private var instrumentTag: String { + switch model.selectedInstrument { + case .auto: + return "" + case .device(let device): + return "\(device.port)" + } + } + + // MARK: - Prompt line + + private var promptLine: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Status") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Text(model.prompt) + .font(.callout) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("spotPrompt") + } + if let error = model.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .accessibilityIdentifier("spotLastError") + .accessibilityValue(error) + } + if !model.log.isEmpty { + ProcessLogView( + lines: model.log, + minHeight: 60, + maxHeight: 100, + containerId: "spotLogContainer", + logId: "spotLog" + ) + } + } + .padding(16) + .background(Theme.panel) + } + + // MARK: - Transport + + private var transport: some View { + HStack(spacing: 12) { + if !model.isRunning { + Button("Start") { model.start() } + .disabled(!model.canStart) + .accessibilityIdentifier("btnSpotStart") + } else { + switch model.state { + case .calibrating: + Button("Calibrate") { model.calibrate() } + .accessibilityIdentifier("btnSpotCalibrate") + case .awaitingStrip: + Button("Read") { model.trigger() } + .accessibilityIdentifier("btnSpotTrigger") + default: + EmptyView() + } + Button("Stop") { model.stopIfNeeded() } + .accessibilityIdentifier("btnSpotStop") + } + Spacer() + } + .padding(.horizontal, 4) + } + + // MARK: - Last sample + + @ViewBuilder + private var lastSampleCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Last sample") + .font(.headline) + .foregroundStyle(Theme.text) + + if let sample = model.displayedSample { + HStack(spacing: 16) { + let rgb = LabColorMath.labToSRGB(sample.lab) + RoundedRectangle(cornerRadius: 4) + .fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b)) + .frame(width: 32, height: 32) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border)) + .accessibilityIdentifier("spotSwatch") + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 12) { + Text(String(format: "L* %.1f", sample.lab.l)) + .accessibilityIdentifier("spotLabL") + Text(String(format: "a* %.1f", sample.lab.a)) + .accessibilityIdentifier("spotLabA") + Text(String(format: "b* %.1f", sample.lab.b)) + .accessibilityIdentifier("spotLabB") + } + .font(.callout) + .foregroundStyle(Theme.text) + + if let xyz = sample.xyz { + Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z)) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotXYZ") + } + + HStack(spacing: 8) { + Text(sample.port.map { "\(sample.instrumentName) · port \($0)" } + ?? sample.instrumentName) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotLastInstrument") + + if let de = model.displayedDeltaE { + HStack(spacing: 6) { + Circle() + .fill(deltaEColor) + .frame(width: 8, height: 8) + Text(String(format: "ΔE %.2f", de)) + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotDeltaE") + } + } + + if model.isDisplayedLabImplausible { + Text("Implausible L*") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("spotLabImplausible") + } + } + Spacer() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotLastSample") + } else { + Text("No readings yet.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotLastEmpty") + } + } + .padding(16) + .background(Theme.panel) + } + + private var deltaEColor: Color { + switch model.deltaEClassification { + case .good, nil: return .green + case .warning: return .orange + case .bad: return .red + } + } + + // MARK: - History + + @ViewBuilder + private var historySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("History") + .font(.headline) + .foregroundStyle(Theme.text) + + if model.samples.isEmpty { + Text("No history.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotHistoryEmpty") + } else { + List { + ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in + historyRow(index: index, sample: sample) + } + } + .frame(minHeight: 120) + .accessibilityIdentifier("spotHistoryTable") + } + } + .padding(16) + .background(Theme.panel) + } + + private func historyRow(index: Int, sample: SpotReadSample) -> some View { + let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil + let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) } + return Button(action: { model.selectFromHistory(sample) }) { + HStack(spacing: 10) { + Text(sample.timestamp, style: .time) + .frame(width: 70, alignment: .leading) + Text(String(format: "%.1f", sample.lab.l)) + .frame(width: 44, alignment: .trailing) + Text(String(format: "%.1f", sample.lab.a)) + .frame(width: 44, alignment: .trailing) + Text(String(format: "%.1f", sample.lab.b)) + .frame(width: 44, alignment: .trailing) + Text(deltaE.map { String(format: "%.2f", $0) } ?? "") + .frame(width: 44, alignment: .trailing) + Text(sample.instrumentName) + .lineLimit(1) + Spacer() + } + .font(.caption) + .foregroundStyle(Theme.text) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)") + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 12) { + Button("Copy Lab") { model.copyLab() } + .disabled(model.displayedSample == nil) + .accessibilityIdentifier("btnSpotCopyLab") + Button("Export CSV…") { model.exportCsv() } + .disabled(model.samples.isEmpty) + .accessibilityIdentifier("btnSpotExportCsv") + Spacer() + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("btnCloseSpotRead") + } + .padding(.top, 4) + } +} diff --git a/Sources/ICCery/SpotReadViewModel.swift b/Sources/ICCery/SpotReadViewModel.swift new file mode 100644 index 0000000..19d311b --- /dev/null +++ b/Sources/ICCery/SpotReadViewModel.swift @@ -0,0 +1,420 @@ +import AppKit +import Combine +import Foundation +import ICCeryCore + +/// Spot-read console state and interaction (issue #148). +/// +/// Runs the bundled `spotread` sidecar under the single-lease process id +/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument` +/// seeds the instrument picker on sheet open — it is never written into +/// `printtarg -i` or `targen` argv (R15). +@MainActor +final class SpotReadViewModel: ObservableObject { + + let workflow: TargetWorkflowViewModel + let environment: AppEnvironment + private let fileDialogs = FileDialogService.shared + + // MARK: - Instrument card + + @Published var instruments: [InstrumentDevice] = [] + @Published var selectedInstrument: InstrumentSelection = .auto + @Published var isDetecting = false + @Published var detectionError: String? + /// `spotDefaultMissing` — set when `defaultInstrument` is saved but + /// no detected device matches it. + @Published var defaultMissing = false + /// `spotSetDefault` toggle state. + @Published var setAsDefault = false + + // MARK: - Session + + @Published var isRunning = false + @Published var state: ChartreadState = .idle + @Published var prompt = "Press Start to open the instrument." + @Published var lastError: String? + @Published var log: [String] = [] + + // MARK: - Samples / history (in-memory, cap 50, newest first) + + @Published private(set) var samples: [SpotReadSample] = [] + @Published private(set) var displayedSample: SpotReadSample? + @Published private(set) var displayedDeltaE: Double? + + private let historyLimit = 50 + private var streamTask: Task? + + init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.environment = environment + } + + // MARK: - Derived state + + /// Whether the bundled `spotread` sidecar resolves to an executable. + /// `BinaryResolver` only — never `$PATH`, never `chartread`. + var sidecarAvailable: Bool { + let url = environment.runner.binaryResolver.resolve("spotread") + return environment.runner.binaryResolver.exists(url) + } + + var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning } + + var canStart: Bool { + sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning + && workflow.wizard.effectiveWorkingDirectory != nil + } + + var canDetect: Bool { !isDetecting && !isRunning } + + var deltaEClassification: SwatchClassification? { + guard let de = displayedDeltaE else { return nil } + let settings = environment.settingsStore.load() + return ColorDifference.classify( + deltaE: de, + goodMax: settings.deltaEGoodMax, + warningMax: settings.deltaEWarningMax) + } + + /// `spotLabImplausible` — L* outside 0…100 still displays, unclamped. + var isDisplayedLabImplausible: Bool { + guard let l = displayedSample?.lab.l else { return false } + return l < 0 || l > 100 + } + + // MARK: - Sheet lifecycle + + /// Called from `SpotReadView.onAppear`. Resets the in-memory session + /// and runs detection once; a missing sidecar gets a wizard notice. + func sheetOpened() { + samples = [] + displayedSample = nil + displayedDeltaE = nil + log = [] + lastError = nil + state = .idle + prompt = "Press Start to open the instrument." + + guard sidecarAvailable else { + workflow.wizard.showNotice( + "spotread sidecar missing — run fetch-argyll", kind: .error) + return + } + detectInstruments() + } + + /// Called from `onDisappear` *and* the sheet's `onDismiss` — clearing + /// the flag alone is not enough; a live child must be quit and + /// killed (R14). + func sheetClosed() { + stopIfNeeded() + samples = [] + displayedSample = nil + displayedDeltaE = nil + log = [] + defaultMissing = false + } + + // MARK: - Detection + + func detectInstruments() { + guard canDetect else { return } + isDetecting = true + detectionError = nil + + Task { @MainActor [weak self] in + guard let self else { return } + // `instlist` is an exclusive lease (#116) — never spawn a + // second one; surface the busy state instead. + if await self.environment.runner.processManager.isRunning(ProcessID.instlist) { + self.detectionError = "Instrument detection is already running." + self.isDetecting = false + return + } + do { + let devices = try await self.environment.runner.detectInstruments() + self.instruments = devices + self.seedDefault(from: 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 + } + } + + /// Seed the picker from `AppSettings.defaultInstrument`; no match → + /// `.auto` + `spotDefaultMissing`. + private func seedDefault(from devices: [InstrumentDevice]) { + guard let code = environment.settingsStore.load().defaultInstrument, + !code.isEmpty else { + defaultMissing = false + return + } + if let match = devices.first(where: { Self.matches(code: code, device: $0) }) { + selectedInstrument = .device(match) + defaultMissing = false + } else { + selectedInstrument = .auto + defaultMissing = true + } + } + + /// Whether an `instlist` device corresponds to a `printtarg -i` / + /// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`). + static func matches(code: String, device: InstrumentDevice) -> Bool { + let haystack = "\(device.name) \(device.type)".lowercased() + switch code { + case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3") + case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3") + case "CM": return haystack.contains("colormunki") + case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan") + case "20": return haystack.contains("display 2") + case "22": return haystack.contains("display") + case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5") + case "51": return haystack.contains("spyder x") + default: return false + } + } + + /// Reverse of `matches` — most specific codes first. + static func code(for device: InstrumentDevice) -> String? { + for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"] + where matches(code: code, device: device) { + return code + } + return nil + } + + /// `spotSetDefault` — writes `AppSettings.defaultInstrument` only. + /// Never touches `printtarg -i` or `targen`. + func applyDefaultToggle(_ on: Bool) { + setAsDefault = on + var settings = environment.settingsStore.load() + if on, case .device(let device) = selectedInstrument { + settings.defaultInstrument = Self.code(for: device) + } else if !on { + settings.defaultInstrument = nil + } + try? environment.settingsStore.save(settings) + } + + // MARK: - Session control + + func start() { + guard sidecarAvailable else { + lastError = "spotread sidecar missing — run fetch-argyll" + return + } + guard !isChartreadRunning else { + lastError = "Stop the Stage 3 chart read first." + return + } + guard let cwd = workflow.wizard.effectiveWorkingDirectory else { + lastError = "Set a working folder in Stage 1 first." + return + } + Task { @MainActor [weak self] in + guard let self else { return } + // `spotread` is an exclusive lease — a second Start while a + // child is live is an error, not a kill + respawn (#116). + if await self.environment.runner.processManager.isRunning(ProcessID.spotread) { + self.lastError = "A spotread session is already running." + return + } + self.begin(config: self.buildConfig(cwd: cwd)) + } + } + + private func buildConfig(cwd: URL) -> SpotReadConfig { + let port: Int? + let name: String + switch selectedInstrument { + case .auto: + port = nil + name = "Auto" + case .device(let device): + port = device.port + name = device.name + } + return SpotReadConfig( + workingDirectory: cwd, + selectedPort: selectedInstrument.chartreadPort, + enableLEDs: environment.settingsStore.load().enableI1Pro2Leds, + isXY: selectedInstrument.isXY, + instrumentName: name, + instrumentPort: port + ) + } + + private func begin(config: SpotReadConfig) { + isRunning = true + state = .idle + lastError = nil + prompt = "Waiting for a reading…" + + let stream = environment.runner.runSpotread(config: config) + streamTask = Task { @MainActor [weak self] in + guard let self else { return } + for await event in stream { + self.handle(event: event) + } + self.isRunning = false + self.state = .idle + if self.lastError == nil { + self.prompt = "Press Start to open the instrument." + } + } + } + + private func handle(event: SpotReadEvent) { + switch event { + case .prompt(let result): + state = result.state + prompt = promptText(for: result.state) + + case .sample(let sample): + let previous = samples.first + samples.insert(sample, at: 0) + if samples.count > historyLimit { + samples.removeLast() + } + displayedSample = sample + displayedDeltaE = previous.map { + ColorDifference.deltaE00($0.lab, sample.lab) + } + + case .log(let batch): + log.append(contentsOf: batch) + + case .exit(let code): + if code != 0 { + lastError = "spotread exited with code \(code)" + } + + case .failed(let error): + lastError = error.localizedDescription + } + } + + private func promptText(for state: ChartreadState) -> String { + switch state { + case .calibrating: + return "Place the instrument on the calibration tile, then Calibrate." + case .awaitingStrip: + return "Place on the patch, then Read." + case .reading, .promptContinue: + return "Waiting for a reading…" + case .warning: + return "Instrument warning — stop and restart if it persists." + case .error: + return "Read error — Stop, then Start again." + default: + return "Waiting for a reading…" + } + } + + // MARK: - Transport + + /// `btnSpotCalibrate` — same bytes Stage 3 sends for calibrate. + func calibrate() { + send(.trigger) + } + + /// `btnSpotTrigger` — the Read key (`" \n"`). + func trigger() { + send(.trigger) + } + + private func send(_ input: ChartreadInput) { + Task { @MainActor [weak self] in + guard let self, self.isRunning else { return } + try? await self.environment.runner.sendSpotreadInput(input) + } + } + + /// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the + /// child is still live. + func stopIfNeeded() { + guard isRunning else { return } + streamTask?.cancel() + streamTask = nil + let processManager = environment.runner.processManager + Task { @MainActor in + try? await processManager.sendStdin( + id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes) + try? await Task.sleep(nanoseconds: 500_000_000) + await processManager.kill(id: ProcessID.spotread) + } + isRunning = false + state = .idle + prompt = "Press Start to open the instrument." + } + + // MARK: - History / export + + /// Click a history row: copies that sample into the last-sample card. + /// Never re-triggers the instrument. + func selectFromHistory(_ sample: SpotReadSample) { + displayedSample = sample + if let index = samples.firstIndex(of: sample), index + 1 < samples.count { + displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab) + } else { + displayedDeltaE = nil + } + } + + /// `btnSpotCopyLab` — `L* a* b*` of the displayed sample as plain + /// text (`50.0 1.2 -3.4`). + func copyLab() { + guard let sample = displayedSample else { return } + let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + /// `btnSpotExportCsv` — RFC-4180 via `selectCsvSavePath`. Cancel is + /// a no-op. Rows are newest-first, matching the history list. + func exportCsv() { + guard !samples.isEmpty else { return } + let url = UITestHooks.isEnabled + ? UITestHooks.csvExportURL + : fileDialogs.selectCsvSavePath() + guard let url else { return } + + var out = "timestamp,L,a,b,dE00,instrument,port\r\n" + for (index, sample) in samples.enumerated() { + let deltaE = index + 1 < samples.count + ? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)) + : "" + out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n" + } + + do { + try out.write(to: url, atomically: true, encoding: .utf8) + workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)") + } catch { + workflow.wizard.showNotice( + "Export failed: \(error.localizedDescription)", kind: .error) + } + } + + private func f1(_ value: Double) -> String { + String(format: "%.1f", value) + } + + private func iso8601(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + private func csvField(_ text: String) -> String { + guard text.contains(",") || text.contains("\"") || text.contains("\n") else { + return text + } + return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\"" + } +} diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index aa2da45..76b20e2 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -102,6 +102,11 @@ final class TargetWorkflowViewModel: ObservableObject { @Published var showingSaveMedia = false @Published var showingManageMedia = false + // MARK: - Spot read (issue #148) + + /// `RootView` sheet binding for the spot-read console. + @Published var showingSpotRead = false + /// Stage 3 measurement workflow, owned at the app level so it persists /// across stage switches and can observe settings changes. @Published var measurement: MeasurementWorkflowViewModel @@ -112,8 +117,10 @@ final class TargetWorkflowViewModel: ObservableObject { @Published var calibration: CalibrationViewModel! /// Stage 2 unmanaged print session. @Published var print: PrintSessionViewModel! - /// Media recipe library, created last — it needs a complete `self`. + /// Media recipe library — needs a complete `self`. @Published var media: MediaLibraryViewModel! + /// Spot-read console, created last — needs `wizard` / `measurement`. + @Published var spotRead: SpotReadViewModel! init(environment: AppEnvironment = .live()) { self.environment = environment @@ -137,6 +144,10 @@ final class TargetWorkflowViewModel: ObservableObject { workflow: self, environment: environment ) + self.spotRead = SpotReadViewModel( + workflow: self, + environment: environment + ) reloadPresets() } diff --git a/Tests/ICCeryCoreTests/SpotReadArgsTests.swift b/Tests/ICCeryCoreTests/SpotReadArgsTests.swift new file mode 100644 index 0000000..49cb326 --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadArgsTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import ICCeryCore + +/// `SpotReadArgs` goldens (issue #148): +/// `spotread -v -e [-c port] [-Y l]` — never `-u`, never a basename, +/// `-c` only for ports > 1, `-Y l` only when the LED setting is on. +final class SpotReadArgsTests: XCTestCase { + + func testAutoOmitsPort() { + let args = SpotReadArgs.build(config: SpotReadConfig()) + XCTAssertEqual(args, ["-v", "-e"]) + } + + func testPort1OmitsC() { + let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 1)) + XCTAssertEqual(args, ["-v", "-e"]) + } + + func testPort2IncludesC() { + let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 2)) + XCTAssertEqual(args, ["-v", "-e", "-c", "2"]) + } + + func testLedFlag() { + let args = SpotReadArgs.build(config: SpotReadConfig(enableLEDs: true)) + XCTAssertEqual(args, ["-v", "-e", "-Y", "l"]) + } + + func testPortAndLeds() { + let args = SpotReadArgs.build( + config: SpotReadConfig(selectedPort: 2, enableLEDs: true)) + XCTAssertEqual(args, ["-v", "-e", "-c", "2", "-Y", "l"]) + } + + func testNeverU() { + for config in [ + SpotReadConfig(), + SpotReadConfig(selectedPort: 2), + SpotReadConfig(enableLEDs: true), + SpotReadConfig(selectedPort: 3, enableLEDs: true), + ] { + XCTAssertFalse(SpotReadArgs.build(config: config).contains("-u")) + XCTAssertFalse(SpotReadArgs.build(config: config).contains("-d")) + } + } +} diff --git a/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift b/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift new file mode 100644 index 0000000..04062ac --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import ICCeryCore + +/// `SpotReadClassifier` / `SpotReadParser` against real `spotread` +/// phrasing (issue #148). The calibration-tile line classifies through +/// `ChartreadClassifier`; the spot prompt and its continuation lines +/// need the spot-specific matchers. +final class SpotReadClassifierTests: XCTestCase { + + // Real `spotread` stdout (calibration then spot prompt). + private let calibrateLines = [ + "Spot read needs a calibration before continuing", + "Place instrument on spot reading white calibration tile,", + " and then hit any key to continue,", + "or hit Esc or Q to abort:", + ] + private let spotPromptLines = [ + "Place instrument on a spot to be measured,", + " and hit a key to take a reading,", + "or hit Esc or Q to abort:", + ] + + func testCalibrationPrompt() { + var state = ChartreadState.idle + for line in calibrateLines { + state = SpotReadClassifier.classify(line: line, previousState: state).state + } + XCTAssertEqual(state, .calibrating) + } + + func testSpotPromptIsAwaitingTrigger() { + var state = ChartreadState.calibrating + for line in spotPromptLines { + state = SpotReadClassifier.classify(line: line, previousState: state).state + } + XCTAssertEqual(state, .awaitingStrip) + } + + func testAbortLineDoesNotBecomeWarning() { + // "or hit Esc or Q to abort:" contains no '?' but does contain + // "abort" — it must stay on the current prompt, never flip to + // a warning. + let r = SpotReadClassifier.classify( + line: "or hit Esc or Q to abort:", previousState: .awaitingStrip) + XCTAssertEqual(r.state, .awaitingStrip) + } + + func testParseResultLine() throws { + let parsed = SpotReadParser.parse( + line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2") + let lab = try XCTUnwrap(parsed?.lab) + XCTAssertEqual(lab.l, 51.9, accuracy: 0.001) + XCTAssertEqual(lab.a, -8.3, accuracy: 0.001) + XCTAssertEqual(lab.b, 12.2, accuracy: 0.001) + let xyz = try XCTUnwrap(parsed?.xyz) + XCTAssertEqual(xyz.x, 18.51, accuracy: 0.001) + XCTAssertEqual(xyz.y, 20.05, accuracy: 0.001) + XCTAssertEqual(xyz.z, 15.71, accuracy: 0.001) + } + + func testParseLabOnlyLine() throws { + let parsed = SpotReadParser.parse(line: "Result is Lab: 40.0 1.2 -3.4") + let lab = try XCTUnwrap(parsed?.lab) + XCTAssertEqual(lab.l, 40.0, accuracy: 0.001) + XCTAssertNil(parsed?.xyz) + } + + func testNonSampleLineParsesNil() { + XCTAssertNil(SpotReadParser.parse(line: "Place instrument on a spot to be measured,")) + XCTAssertNil(SpotReadParser.parse(line: "Calibration successful.")) + XCTAssertNil(SpotReadParser.parse(line: "")) + } + + func testDeltaEBetweenFixtures() { + let a = SpotReadParser.parse( + line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")!.lab + let b = SpotReadParser.parse( + line: "Result is XYZ: 19.00 20.50 16.00, D50 Lab: 52.3 -8.0 12.6")!.lab + XCTAssertEqual(ColorDifference.deltaE00(a, a), 0, accuracy: 0.0001) + XCTAssertGreaterThan(ColorDifference.deltaE00(a, b), 0) + XCTAssertEqual( + ColorDifference.classify(deltaE: 1.0, goodMax: 2.0, warningMax: 5.0), + .good) + XCTAssertEqual( + ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), + .warning) + XCTAssertEqual( + ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), + .bad) + } +} diff --git a/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift b/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift new file mode 100644 index 0000000..c6a4ffc --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift @@ -0,0 +1,250 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #148 — `SpotReadViewModel` under an isolated +/// `TestAppEnvironment` with per-test mock `spotread`/`instlist` +/// sidecars in a temp bin dir. +@MainActor +final class SpotReadViewModelTests: XCTestCase { + + private var env: TestAppEnvironment! + private var workflow: TargetWorkflowViewModel! + private var spot: SpotReadViewModel! + private var binDir: URL! + + override func setUp() async throws { + binDir = FileManager.default.temporaryDirectory + .appendingPathComponent("spot-bin-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: binDir, withIntermediateDirectories: true) + // `bundledArgyllRoot` also points at the temp bin dir so the + // real sidecars copied into the host app by the build phase do + // not mask a missing `spotread` in the override dir. + env = try TestAppEnvironment.make( + argyllBinDir: binDir, bundledArgyllRoot: binDir) + workflow = TargetWorkflowViewModel(environment: env.environment) + spot = workflow.spotRead + workflow.wizard.setTarget(basename: "spot", workingDirectory: env.root) + } + + override func tearDown() async throws { + spot?.stopIfNeeded() + try? await Task.sleep(nanoseconds: 700_000_000) + env?.cleanup() + try? FileManager.default.removeItem(at: binDir) + env = nil + workflow = nil + spot = nil + binDir = nil + } + + // MARK: - Helpers + + private func writeMock(_ name: String, _ body: String) throws { + let url = binDir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func installInstlist(_ devicesJson: String) throws { + try writeMock("instlist", """ + #!/bin/sh + printf '%s' '\(devicesJson)' + exit 0 + """) + } + + private func installSpotread(lab: String = "51.9 -8.3 12.2") throws { + try writeMock("spotread", """ + #!/bin/sh + echo "Spot read needs a calibration before continuing" + echo "Place instrument on spot reading white calibration tile," + echo " and then hit any key to continue," + echo "or hit Esc or Q to abort:" + IFS= read -r line || exit 0 + echo "Calibration successful." + while true; do + echo "Place instrument on a spot to be measured," + echo " and hit a key to take a reading," + echo "or hit Esc or Q to abort:" + IFS= read -r line || exit 0 + case "$line" in + q*|Q*) exit 0 ;; + esac + echo "Result is XYZ: 18.51 20.05 15.71, D50 Lab: \(lab)" + done + """) + } + + private func waitFor( + _ predicate: @escaping () async -> Bool, + timeout: TimeInterval = 10 + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await predicate() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return await predicate() + } + + private func waitForSync( + _ predicate: @escaping () -> Bool, + timeout: TimeInterval = 10 + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if predicate() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return predicate() + } + + // MARK: - Missing sidecar + + func testMissingSidecarNoSpawn() async throws { + // bin dir has no spotread → resolver override misses and the + // bundled path does not exist either. + XCTAssertFalse(spot.sidecarAvailable) + spot.sheetOpened() + XCTAssertEqual(workflow.wizard.notice?.kind, .error) + + spot.start() + XCTAssertEqual(spot.lastError, "spotread sidecar missing — run fetch-argyll") + XCTAssertFalse(spot.isRunning) + let running = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + XCTAssertFalse(running) + } + + // MARK: - defaultInstrument seeding + + func testDefaultInstrumentSeedsPicker() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":1,"name":"X-Rite i1Pro","type":"i1"}, + {"port":2,"name":"ColorMunki Photo","type":"CM"}]} + """) + var settings = env.environment.settingsStore.load() + settings.defaultInstrument = "CM" + try env.environment.settingsStore.save(settings) + + spot.sheetOpened() + let ok1 = await waitFor { !self.spot.isDetecting && !self.spot.instruments.isEmpty } + XCTAssertTrue(ok1) + guard case .device(let device) = spot.selectedInstrument else { + XCTFail("Expected device selection, got .auto") + return + } + XCTAssertEqual(device.port, 2) + XCTAssertFalse(spot.defaultMissing) + } + + func testDefaultInstrumentNotPresent() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":1,"name":"X-Rite i1Pro","type":"i1"}]} + """) + var settings = env.environment.settingsStore.load() + settings.defaultInstrument = "51" // Spyder X — absent + try env.environment.settingsStore.save(settings) + + spot.sheetOpened() + let ok2 = await waitFor { !self.spot.isDetecting } + XCTAssertTrue(ok2) + XCTAssertEqual(spot.selectedInstrument, .auto) + XCTAssertTrue(spot.defaultMissing) + } + + func testSetDefaultToggleWritesSettingsOnly() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":2,"name":"ColorMunki Photo","type":"CM"}]} + """) + spot.sheetOpened() + let ok3 = await waitFor { !self.spot.instruments.isEmpty } + XCTAssertTrue(ok3) + spot.selectedInstrument = .device(spot.instruments[0]) + spot.applyDefaultToggle(true) + XCTAssertEqual(env.environment.settingsStore.load().defaultInstrument, "CM") + // printtarg instrument is untouched (R15). + XCTAssertEqual(workflow.instrument, .i1) + spot.applyDefaultToggle(false) + XCTAssertNil(env.environment.settingsStore.load().defaultInstrument) + } + + // MARK: - Exclusive lease + + func testDuplicateSpotreadIdRejected() async throws { + try writeMock("spotread", "#!/bin/sh\nsleep 30\n") + let pm = env.environment.runner.processManager + let bin = env.environment.runner.binaryResolver.resolve("spotread") + try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: []) + let ok4 = await pm.isRunning(ProcessID.spotread) + XCTAssertTrue(ok4) + do { + try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: []) + XCTFail("Expected duplicateID") + } catch let error as ProcessError { + guard case .duplicateID(let id) = error else { + XCTFail("Expected duplicateID, got \(error)") + return + } + XCTAssertEqual(id, "spotread") + } + await pm.kill(id: ProcessID.spotread) + } + + // MARK: - Session + + func testMockSpotreadProducesSample() async throws { + try installSpotread() + spot.sheetOpened() + spot.start() + let ok5 = await waitFor { self.spot.state == .calibrating } + XCTAssertTrue(ok5, "expected calibrating prompt") + + spot.calibrate() + let ok6 = await waitFor { self.spot.state == .awaitingStrip } + XCTAssertTrue(ok6, "expected read prompt") + + spot.trigger() + let ok7 = await waitFor { !self.spot.samples.isEmpty } + XCTAssertTrue(ok7, "expected a sample") + let sample = try XCTUnwrap(spot.samples.first) + XCTAssertEqual(sample.lab.l, 51.9, accuracy: 0.001) + XCTAssertNotNil(sample.xyz) + XCTAssertNil(spot.displayedDeltaE) // first sample hides ΔE + + spot.trigger() + let ok8 = await waitFor { self.spot.samples.count >= 2 } + XCTAssertTrue(ok8, "expected a second sample") + XCTAssertNotNil(spot.displayedDeltaE) + XCTAssertEqual(spot.displayedDeltaE ?? -1, 0, accuracy: 0.0001) // identical Lab + + spot.stopIfNeeded() + XCTAssertFalse(spot.isRunning) + let deadline = Date().addingTimeInterval(5) + var alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + while alive && Date() < deadline { + try await Task.sleep(nanoseconds: 100_000_000) + alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + } + XCTAssertFalse(alive, "spotread child must not outlive Stop") + } + + func testStartBlockedWhileChartreadRunning() async throws { + try installSpotread() + // Simulate a live Stage 3 chartread child. + workflow.measurement.isChartreadRunning = true + spot.sheetOpened() + spot.start() + XCTAssertEqual(spot.lastError, "Stop the Stage 3 chart read first.") + XCTAssertFalse(spot.isRunning) + } +} diff --git a/Tests/ICCeryCoreTests/TestAppEnvironment.swift b/Tests/ICCeryCoreTests/TestAppEnvironment.swift index 4594be8..20cfda0 100644 --- a/Tests/ICCeryCoreTests/TestAppEnvironment.swift +++ b/Tests/ICCeryCoreTests/TestAppEnvironment.swift @@ -25,7 +25,15 @@ struct TestAppEnvironment { /// Creates an isolated environment under `NSTemporaryDirectory()`. /// Call `cleanup()` when finished. - static func make() throws -> TestAppEnvironment { + /// `argyllBinDir` overrides the `BinaryResolver` tool directory so + /// tests can point at mock sidecar scripts (#148). + /// `bundledArgyllRoot` replaces the real app-bundle sidecar root so + /// tests can simulate a missing sidecar even when the build phase + /// copied real binaries into the host app. + static func make( + argyllBinDir: URL? = nil, + bundledArgyllRoot: URL? = nil + ) throws -> TestAppEnvironment { let root = FileManager.default.temporaryDirectory .appendingPathComponent("iccery-test-env-\(UUID().uuidString)") try FileManager.default.createDirectory( @@ -44,7 +52,9 @@ struct TestAppEnvironment { presetStore: PresetStore(settingsStore: settingsStore), runner: ArgyllRunner( processManager: processManager, - binaryResolver: BinaryResolver(overrideDir: nil) + binaryResolver: BinaryResolver( + bundledRoot: bundledArgyllRoot ?? AppPaths.bundledArgyllDir, + overrideDir: argyllBinDir) ), cupsService: CupsService( processManager: processManager, diff --git a/Tests/ICCeryUITests/Fixtures/bin/spotread b/Tests/ICCeryUITests/Fixtures/bin/spotread new file mode 100755 index 0000000..fb88e77 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/spotread @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Mock spotread for Milestone10SpotReadUITests. + +Emits the real spotread prompt phrasing; each trigger line produces one +"Result is XYZ: …, D50 Lab: …" sample. Override the emitted colour with +MOCK_SPOTREAD_LAB / MOCK_SPOTREAD_XYZ. 'q' quits with exit 0. +Usage: spotread -v -e [-c port] [-Y l] +""" +import os +import sys + +LAB = os.environ.get("MOCK_SPOTREAD_LAB", "51.9 -8.3 12.2") +XYZ = os.environ.get("MOCK_SPOTREAD_XYZ", "18.51 20.05 15.71") + + +def read_line(): + try: + return sys.stdin.readline() + except Exception: + return "" + + +def main(): + print("Spot read needs a calibration before continuing") + print("Place instrument on spot reading white calibration tile,") + print(" and then hit any key to continue,") + print("or hit Esc or Q to abort:") + sys.stdout.flush() + if not read_line(): + return 0 + print("Calibration successful.") + + while True: + print("Place instrument on a spot to be measured,") + print(" and hit a key to take a reading,") + print("or hit Esc or Q to abort:") + sys.stdout.flush() + line = read_line() + if not line: + return 0 + if line.strip().lower().startswith("q"): + return 0 + print("Result is XYZ: %s, D50 Lab: %s" % (XYZ, LAB)) + sys.stdout.flush() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift b/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift new file mode 100644 index 0000000..885bbeb --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift @@ -0,0 +1,235 @@ +import XCTest + +/// Milestone 10 UI tests — issue #148 spot-read console. Mock Argyll +/// sidecars (`ICCERY_ARGYLL_BINARY_DIR` → `Fixtures/bin`) provide +/// `instlist`, `chartread`, and `spotread`; no real USB Detect is ever +/// clicked. All queries are by identifier only. +@MainActor +final class Milestone10SpotReadUITests: 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-ui10spot-\(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, + // Redirect the bundled root too so the real sidecars copied + // into the product by the build phase cannot mask a missing + // override binary (`testMissingSidecarShowsMessage`). + "ICCERY_ARGYLL_BUNDLED_ROOT": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_TEST_CSV_EXPORT": + workDir.appendingPathComponent("spot-history.csv").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() + } + } + + /// Seed `wizard_state.json` with a working directory so `btnSpotRead` + /// is enabled without driving the whole Stage 1/2 flow. + private func seedWorkingDirectory() throws { + let appData = testRoot.appendingPathComponent("AppData", isDirectory: true) + try FileManager.default.createDirectory( + at: appData, withIntermediateDirectories: true) + let state = """ + { + "currentStage": 0, + "basename": "spotui", + "cwd": "\(workDir.path)", + "sessionMode": "profile" + } + """ + try state.write( + to: appData.appendingPathComponent("wizard_state.json"), + atomically: true, encoding: .utf8) + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id].firstMatch + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch + } + + 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 + } + + // MARK: - Sidebar gating + + func testSpotReadButtonDisabledWithoutCwd() throws { + launchApp() + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + XCTAssertFalse(button.isEnabled) + } + + func testSpotReadButtonDisabledDuringChartread() throws { + launchApp() + // Drive to Stage 3 with the mock targen/printtarg fixtures. + 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) + + // Start the mock chartread — it blocks on the calibrate prompt. + app.buttons["btnStartRead"].click() + _ = waitFor("btnCalibrate", timeout: 25) + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.exists) + XCTAssertFalse(button.isEnabled) + + // Clean up the live chartread child before teardown. + if app.buttons["btnCancel"].exists { + app.buttons["btnCancel"].click() + } + } + + // MARK: - Sheet contract + + func testSheetHasOwnInstrumentIds() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertTrue(button.isEnabled) + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotInstrumentSelect").waitForExistence(timeout: 10)) + // Stage 3 ids must not appear inside the sheet. + XCTAssertFalse( + app.sheets.firstMatch.descendants(matching: .any)["chartreadInstrumentSelect"].exists) + XCTAssertFalse( + app.sheets.firstMatch.descendants(matching: .any)["btnDetectInstruments"].exists) + XCTAssertTrue(element("btnCloseSpotRead").exists) + } + + func testMissingSidecarShowsMessage() throws { + // Point the override at an empty dir; the bundled root has no + // real sidecars in this checkout, so resolve() misses both. + let emptyBin = testRoot.appendingPathComponent("empty-bin") + try FileManager.default.createDirectory( + at: emptyBin, withIntermediateDirectories: true) + app.launchEnvironment["ICCERY_ARGYLL_BINARY_DIR"] = emptyBin.path + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotSidecarMissing").waitForExistence(timeout: 10)) + XCTAssertFalse(element("btnSpotStart").exists) + XCTAssertFalse(element("btnSpotDetectInstruments").exists) + XCTAssertTrue(element("btnCloseSpotRead").exists) + } + + func testHistoryCopyDisabledWhenEmpty() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotHistoryEmpty").waitForExistence(timeout: 10)) + XCTAssertTrue(element("spotLastEmpty").exists) + XCTAssertFalse(element("btnSpotCopyLab").isEnabled) + XCTAssertFalse(element("btnSpotExportCsv").isEnabled) + } + + /// Full mock session: Start → Calibrate → Read produces one Lab + /// sample and enables Copy/Export. + func testMockSessionProducesSample() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + let start = element("btnSpotStart") + XCTAssertTrue(start.waitForExistence(timeout: 10)) + start.click() + + XCTAssertTrue(element("btnSpotCalibrate").waitForExistence(timeout: 15)) + element("btnSpotCalibrate").click() + + XCTAssertTrue(element("btnSpotTrigger").waitForExistence(timeout: 15)) + element("btnSpotTrigger").click() + + XCTAssertTrue(element("spotLastSample").waitForExistence(timeout: 15)) + XCTAssertTrue(element("spotLabL").exists) + XCTAssertTrue(element("spotSwatch").exists) + XCTAssertTrue(element("btnSpotCopyLab").isEnabled) + XCTAssertTrue(element("btnSpotExportCsv").isEnabled) + + element("btnSpotStop").click() + element("btnCloseSpotRead").click() + } +} diff --git a/docs/04-argyll-binaries.md b/docs/04-argyll-binaries.md index 9c06ff4..f94dedc 100644 --- a/docs/04-argyll-binaries.md +++ b/docs/04-argyll-binaries.md @@ -695,7 +695,7 @@ Consumes ICC/ICM. Produces `{stem}.gam` next to the profile (Argyll default). Bu |---|---| | `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix | | `dispread` | Same | -| `spotread`, `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced | +| `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced | | Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:6–14`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch | --- @@ -725,6 +725,7 @@ The `Child` itself lives only in the wait task (not in a map) so `wait()` cannot | `profcheck_{ti3_path}` | profcheck (full path) | | `iccgamut_{stem}` | iccgamut | | `instlist` | instlist (literal) | +| `spotread` | spotread (literal, issue #148) | | caller-supplied | unused `spawn_process` | ### 12.3 Duplicate rejection (ICCery#116, `07d28eb`) @@ -830,6 +831,7 @@ From `lib.rs:54–119` plus the command bodies: | `run_profcheck` | profcheck | `profcheck_{ti3_path}` | | `extract_gamut` | iccgamut | `iccgamut_{stem}` | | `detect_instruments` | instlist | `instlist` | +| `run_spotread` | spotread (`-v -e [-c port] [-Y l]`, no `-u`) | `spotread` | | `generate_calibration_target` | targen | `targen_{CAL_basename}` | ### Argyll runners (captured, no events) diff --git a/docs/15-stage3-chartread.md b/docs/15-stage3-chartread.md index 15a18d3..c033932 100644 --- a/docs/15-stage3-chartread.md +++ b/docs/15-stage3-chartread.md @@ -80,3 +80,14 @@ If an unpatched binary rejects `-Y l`, capture last stderr line and expand Proce ## Interactive buttons vs real keys See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests. + +## Spot Read (issue #148) + +The Spot Read sheet (`btnSpotRead` in the sidebar) runs the bundled +`spotread` sidecar under the single-lease process id `spotread` — it is +**not** Stage 3 and shares no identifiers or process ids with +`chartread`. Stage 3 is unchanged: `chartread_{basename}` remains the +only chart path. `spotread` argv is `-v -e [-c port] [-Y l]` — never +`-u` (the v2.0 `-u` policy covers printtarg + chartread + profcheck +only). Stdin reuses the `chartread` byte table (`" \n"` trigger, +`"q\n"` quit + ~500 ms + kill). diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index d2006b7..f4675f9 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -1,14 +1,14 @@ # 21 — UI reference -Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below are the functional contract. **267 element ids** in `src/index.html` — complete roster at the end. +Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below are the functional contract. **298 element ids** in `src/index.html` — complete roster at the end. ## Shell -- Sidebar 270 px: logo `./assets/ICCery-logo.svg`, settings/about icon buttons, preset select, media recipe select + Capture/Manage, Calibrate Printer, cal status chip, stepper 1–5. +- Sidebar 270 px: logo `./assets/ICCery-logo.svg`, settings/about icon buttons, preset select, media recipe select + Capture/Manage, Calibrate Printer, View Gamut, Spot Read, cal status chip, stepper 1–5. - Main: notification banner, one visible `.stage`. - Window 1280×800, min 1100×700, hidden until paint, dark `#1A1A22`. -Sidebar chrome ids: `openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `btnCalibratePrinter`, `calStatusChip`. +Sidebar chrome ids: `openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `btnCalibratePrinter`, `btnSpotRead`, `calStatusChip`. Banner: `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`. Auto-hides via `wizardState.noticeTimer`. @@ -74,11 +74,12 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable | Manage media recipes | `manageMediaDialog` | `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog` | | Cal collision | `calCollisionDialog` | Overwrite / Rename / Cancel | | Profile install collision | `profileInstallCollisionDialog` | `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn` | +| Spot read | `spotReadView` | `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `spotSidecarMissing`, `btnCloseSpotRead` | ## Dialogs must go through host APIs Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). Bugs #103, #210, #211 were exactly this. -## Complete `id=` roster (267) +## Complete `id=` roster (298) -`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`. +`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`.