feat(spotread): spot-read console for live Lab (#148) #152
@@ -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<SpotReadEvent> {
|
||||||
|
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
|
// MARK: - Stage 0 calibration
|
||||||
|
|
||||||
/// Generates a calibration wedge `.ti1`.
|
/// Generates a calibration wedge `.ti1`.
|
||||||
@@ -828,6 +970,20 @@ public enum ChartreadEvent: Sendable {
|
|||||||
case failed(ArgyllRunnerError)
|
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.
|
/// Exact bytes sent to `chartread` stdin.
|
||||||
public enum ChartreadInput: Sendable {
|
public enum ChartreadInput: Sendable {
|
||||||
case trigger // " \n"
|
case trigger // " \n"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: <x> <y> <z>, D50 Lab: <L> <a> <b>`; 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import Foundation
|
|||||||
/// filter events on `id` — historical bug #56 was an id mismatch.
|
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||||
public enum ProcessID {
|
public enum ProcessID {
|
||||||
public static let instlist = "instlist"
|
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 targen(_ basename: String) -> String { "targen_\(basename)" }
|
||||||
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ struct AppEnvironment: Sendable {
|
|||||||
let settingsStore = SettingsStore()
|
let settingsStore = SettingsStore()
|
||||||
var overrideDir = settingsStore.load().argyllBinaryDir
|
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||||
.map { URL(fileURLWithPath: $0) }
|
.map { URL(fileURLWithPath: $0) }
|
||||||
|
var bundledRoot = AppPaths.bundledArgyllDir
|
||||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||||
overrideDir = URL(fileURLWithPath: dir)
|
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 {
|
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||||
cupsDir = URL(fileURLWithPath: dir)
|
cupsDir = URL(fileURLWithPath: dir)
|
||||||
}
|
}
|
||||||
@@ -36,7 +40,8 @@ struct AppEnvironment: Sendable {
|
|||||||
presetStore: PresetStore(settingsStore: settingsStore),
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
runner: ArgyllRunner(
|
runner: ArgyllRunner(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
binaryResolver: BinaryResolver(
|
||||||
|
bundledRoot: bundledRoot, overrideDir: overrideDir)
|
||||||
),
|
),
|
||||||
cupsService: CupsService(
|
cupsService: CupsService(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
@@ -76,6 +81,8 @@ enum UITestHooks {
|
|||||||
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||||
/// Preset export destination.
|
/// Preset export destination.
|
||||||
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
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)
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ struct RootView: View {
|
|||||||
) {
|
) {
|
||||||
ManageMediaDialog(workflow: workflow)
|
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) {
|
.sheet(isPresented: $showingAbout) {
|
||||||
AboutView { showingAbout = false }
|
AboutView { showingAbout = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ struct SettingsView: View {
|
|||||||
Text($0.label).tag($0.code)
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct SidebarView: View {
|
|||||||
@ObservedObject private var profile: ProfileWorkflowViewModel
|
@ObservedObject private var profile: ProfileWorkflowViewModel
|
||||||
@ObservedObject private var media: MediaLibraryViewModel
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
@ObservedObject private var printSession: PrintSessionViewModel
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
@ObservedObject private var measurement: MeasurementWorkflowViewModel
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
var onOpenAbout: () -> Void
|
var onOpenAbout: () -> Void
|
||||||
@Binding var showingAllHelp: Bool
|
@Binding var showingAllHelp: Bool
|
||||||
@@ -26,6 +27,7 @@ struct SidebarView: View {
|
|||||||
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
||||||
self._media = ObservedObject(wrappedValue: workflow.media)
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
|
||||||
self.onOpenSettings = onOpenSettings
|
self.onOpenSettings = onOpenSettings
|
||||||
self.onOpenAbout = onOpenAbout
|
self.onOpenAbout = onOpenAbout
|
||||||
self._showingAllHelp = showingAllHelp
|
self._showingAllHelp = showingAllHelp
|
||||||
@@ -161,6 +163,26 @@ struct SidebarView: View {
|
|||||||
.accessibilityIdentifier("btnViewGamut")
|
.accessibilityIdentifier("btnViewGamut")
|
||||||
.padding(.horizontal, 12)
|
.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)
|
Divider().overlay(Theme.border)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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: "\"\""))\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,11 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
@Published var showingSaveMedia = false
|
@Published var showingSaveMedia = false
|
||||||
@Published var showingManageMedia = 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
|
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can observe settings changes.
|
/// across stage switches and can observe settings changes.
|
||||||
@Published var measurement: MeasurementWorkflowViewModel
|
@Published var measurement: MeasurementWorkflowViewModel
|
||||||
@@ -112,8 +117,10 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
@Published var calibration: CalibrationViewModel!
|
@Published var calibration: CalibrationViewModel!
|
||||||
/// Stage 2 unmanaged print session.
|
/// Stage 2 unmanaged print session.
|
||||||
@Published var print: PrintSessionViewModel!
|
@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!
|
@Published var media: MediaLibraryViewModel!
|
||||||
|
/// Spot-read console, created last — needs `wizard` / `measurement`.
|
||||||
|
@Published var spotRead: SpotReadViewModel!
|
||||||
|
|
||||||
init(environment: AppEnvironment = .live()) {
|
init(environment: AppEnvironment = .live()) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -137,6 +144,10 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
workflow: self,
|
workflow: self,
|
||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
|
self.spotRead = SpotReadViewModel(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
reloadPresets()
|
reloadPresets()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,15 @@ struct TestAppEnvironment {
|
|||||||
|
|
||||||
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||||
/// Call `cleanup()` when finished.
|
/// 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
|
let root = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
@@ -44,7 +52,9 @@ struct TestAppEnvironment {
|
|||||||
presetStore: PresetStore(settingsStore: settingsStore),
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
runner: ArgyllRunner(
|
runner: ArgyllRunner(
|
||||||
processManager: processManager,
|
processManager: processManager,
|
||||||
binaryResolver: BinaryResolver(overrideDir: nil)
|
binaryResolver: BinaryResolver(
|
||||||
|
bundledRoot: bundledArgyllRoot ?? AppPaths.bundledArgyllDir,
|
||||||
|
overrideDir: argyllBinDir)
|
||||||
),
|
),
|
||||||
cupsService: CupsService(
|
cupsService: CupsService(
|
||||||
processManager: processManager,
|
processManager: processManager,
|
||||||
|
|||||||
Executable
+48
@@ -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())
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
|
| `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
|
||||||
| `dispread` | Same |
|
| `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 |
|
| 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) |
|
| `profcheck_{ti3_path}` | profcheck (full path) |
|
||||||
| `iccgamut_{stem}` | iccgamut |
|
| `iccgamut_{stem}` | iccgamut |
|
||||||
| `instlist` | instlist (literal) |
|
| `instlist` | instlist (literal) |
|
||||||
|
| `spotread` | spotread (literal, issue #148) |
|
||||||
| caller-supplied | unused `spawn_process` |
|
| caller-supplied | unused `spawn_process` |
|
||||||
|
|
||||||
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
|
### 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}` |
|
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
|
||||||
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
|
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
|
||||||
| `detect_instruments` | instlist | `instlist` |
|
| `detect_instruments` | instlist | `instlist` |
|
||||||
|
| `run_spotread` | spotread (`-v -e [-c port] [-Y l]`, no `-u`) | `spotread` |
|
||||||
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
|
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
|
||||||
|
|
||||||
### Argyll runners (captured, no events)
|
### Argyll runners (captured, no events)
|
||||||
|
|||||||
@@ -80,3 +80,14 @@ If an unpatched binary rejects `-Y l`, capture last stderr line and expand Proce
|
|||||||
## Interactive buttons vs real keys
|
## 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.
|
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).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user