Compare commits

...
Author SHA1 Message Date
gronod 538ef1e05f Merge pull request 'feat(spotread): spot-read console for live Lab (#148)' (#152) from feat/148-spot-read into milestone/m10-studio 2026-09-13 01:04:45 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d40932f4cf feat(spotread): spot-read console for live Lab (#148)
Refs #148

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 01:03:57 +01:00
gronod 1931da8448 Merge pull request 'feat(media): media recipe library bound to presets (#146)' (#151) from feat/146-media-library into milestone/m10-studio 2026-09-12 23:00:21 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f896f60d1a feat(media): media recipe library bound to presets (#146)
Refs #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 22:59:35 +01:00
gronod 2f305c481e Merge pull request 'docs(m10): record studio-workflow milestone tracking' (#150) from chore/m10-tracking into milestone/m10-studio 2026-09-12 22:18:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4249067345 docs(m10): record studio-workflow milestone tracking
Refs #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 22:18:21 +01:00
34 changed files with 3769 additions and 24 deletions
+7 -2
View File
@@ -1,6 +1,6 @@
# BUILD-PLAN.md — ICCery v2 Mac
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + Later.
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + M10 (id 32) + Later.
## Sprint rule
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
@@ -17,9 +17,14 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| M7 | Deduplicate & consolidate | 7986 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
| M8 | Deduplicate & consolidate | 7986 | (already shipped on `develop`) | N/A |
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
| M10 | Studio workflow | 146149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
Issue **16 is not an M3 or M6 exit gate.**
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
Issue **16 is not an M3, M6, or M10 exit gate.**
## Branch taxonomy
@@ -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
/// 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"
@@ -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,117 @@
import Foundation
/// Persistence for `MediaRecipe` entries (issue #146).
///
/// `media_library.json` is a sibling of `settings.json`, never a field
/// inside it. Writes are atomic via `JSONFileStore` `AtomicFileWriter`
/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is
/// never overwritten the view model turns the throw into an empty
/// list plus a persistent warning banner.
public actor MediaLibraryStore {
/// Default cap.
public static let defaultCapacity = 200
/// Path to the JSON store.
public let url: URL
/// In-memory cache, kept in sync with disk.
private var recipes: [MediaRecipe] = []
/// Explicit load flag an empty file is still "loaded".
private var loaded = false
private let capacity: Int
private let fileStore: JSONFileStore<[MediaRecipe]>
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"),
capacity: Int = defaultCapacity
) {
self.url = url
self.capacity = capacity
self.fileStore = JSONFileStore(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] },
dateEncoding: .iso8601,
dateDecoding: .iso8601
)
}
/// Loads recipes from disk. Returns the existing cache if already
/// loaded.
///
/// Throws when the file exists but cannot be parsed; the existing
/// file is never overwritten in that case and `loaded` stays false
/// so the next call re-reads.
public func load() throws -> [MediaRecipe] {
guard !loaded else { return recipes }
guard FileManager.default.fileExists(atPath: url.path) else {
loaded = true
return []
}
recipes = try fileStore.load()
loaded = true
return recipes
}
/// Returns all cached recipes.
public func all() -> [MediaRecipe] {
recipes
}
/// Inserts or replaces a recipe matched by `id`, then writes
/// atomically. Replacement preserves `created` and bumps `updated`;
/// inserts beyond `capacity` throw `.capacityReached` no silent
/// eviction.
///
/// Loads the existing library first and propagates any load error
/// so an unparseable file is never overwritten.
@discardableResult
public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] {
let validated = try recipe.validated()
try load()
var updated = recipes
if let index = updated.firstIndex(where: { $0.id == validated.id }) {
var existing = validated
existing.created = updated[index].created
existing.updated = Date()
updated[index] = existing
} else {
guard updated.count < capacity else {
throw MediaLibraryError.capacityReached(capacity)
}
updated.append(validated)
}
try fileStore.save(updated)
recipes = updated
return updated
}
/// Removes a recipe by id and writes atomically. Returns false when
/// no recipe with that id exists.
@discardableResult
public func delete(id: String) throws -> Bool {
try load()
let before = recipes.count
let updated = recipes.filter { $0.id != id }
guard updated.count != before else { return false }
try fileStore.save(updated)
recipes = updated
return true
}
public enum MediaLibraryError: LocalizedError, Equatable {
case capacityReached(Int)
public var errorDescription: String? {
switch self {
case .capacityReached(let cap):
return "Media library is full (\(cap)). Delete a recipe first."
}
}
}
}
@@ -0,0 +1,146 @@
import Foundation
/// A media recipe: a named binding of CUPS queue + paper + ink set +
/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media
/// library).
///
/// snake_case keys match the v1 JSON schema so `media_library.json`
/// stays import/export compatible. Identity + binding fields are
/// required; every other field is optional-defaulted. Unknown keys are
/// ignored on decode; missing required fields fail the whole array
/// decode (corrupt-file policy, never silently dropped).
public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
/// `"recipe-<uuid>"`, never user-typed.
public var id: String
public var name: String
public var notes: String
/// CUPS queue id (`Printer.name` `Printer` has no `id` member).
public var printerID: String
/// Human label from `Printer.displayName`.
public var printerDisplayName: String
/// Library metadata only never written to targen `-P`/`-I` flags.
public var paperName: String
/// Last captured CUPS `media_type`, read-only.
public var driverMediaType: String?
/// Free text: `"PK"`, `"MK"`, `"Photo Black"`,
public var inkSet: String
/// `"rgb"` | `"cmyk"` must match the bound preset.
public var colourSpace: String
/// `ProfilingPreset.id` (built-in or custom).
public var presetID: String
/// Absolute `.cal` path stored verbatim; `nil` = none.
public var calibrationURL: String?
public var applyCalibration: Bool
public var created: Date
public var updated: Date
public init(
id: String,
name: String,
notes: String = "",
printerID: String,
printerDisplayName: String = "",
paperName: String = "",
driverMediaType: String? = nil,
inkSet: String = "",
colourSpace: String,
presetID: String,
calibrationURL: String? = nil,
applyCalibration: Bool = false,
created: Date = Date(),
updated: Date = Date()
) {
self.id = id
self.name = name
self.notes = notes
self.printerID = printerID
self.printerDisplayName = printerDisplayName
self.paperName = paperName
self.driverMediaType = driverMediaType
self.inkSet = inkSet
self.colourSpace = colourSpace
self.presetID = presetID
self.calibrationURL = calibrationURL
self.applyCalibration = applyCalibration
self.created = created
self.updated = updated
}
enum CodingKeys: String, CodingKey {
case id, name, notes
case printerID = "printer_id"
case printerDisplayName = "printer_display_name"
case paperName = "paper_name"
case driverMediaType = "driver_media_type"
case inkSet = "ink_set"
case colourSpace = "colour_space"
case presetID = "preset_id"
case calibrationURL = "calibration_url"
case applyCalibration = "apply_calibration"
case created, updated
}
/// Strict decode: required identity + binding fields must be
/// present; optionals default. Unknown keys are ignored.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
printerID = try c.decode(String.self, forKey: .printerID)
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? ""
paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? ""
driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType)
inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? ""
colourSpace = try c.decode(String.self, forKey: .colourSpace)
presetID = try c.decode(String.self, forKey: .presetID)
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false
created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date()
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
}
public enum ValidationError: LocalizedError, Equatable {
case emptyName
case emptyPrinterID
case invalidColourSpace(String)
case emptyPresetID
case invalidCalibrationURL(String)
public var errorDescription: String? {
switch self {
case .emptyName: return "Media recipe is missing a name."
case .emptyPrinterID: return "Media recipe is missing a printer."
case .invalidColourSpace(let v):
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
case .emptyPresetID: return "Media recipe is missing a preset."
case .invalidCalibrationURL(let v):
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
}
}
}
/// Validates the binding fields. `colourSpace` is normalized to
/// lowercase before comparison. `CAL_` cal names are **not**
/// rejected that is an apply-time policy, not schema.
@discardableResult
public func validated() throws -> MediaRecipe {
var r = self
r.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
r.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
r.colourSpace = colourSpace.lowercased()
guard !r.name.isEmpty else { throw ValidationError.emptyName }
guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID }
guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else {
throw ValidationError.invalidColourSpace(colourSpace)
}
guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID }
if let cal = r.calibrationURL, !cal.isEmpty {
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
throw ValidationError.invalidCalibrationURL(cal)
}
}
return r
}
}
@@ -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 0100 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.
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)" }
+5 -3
View File
@@ -12,7 +12,9 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
| Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79#86) — in flight on `milestone/m8-consolidation` |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79#86) — shipped on `develop` |
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) |
| M10 | Studio workflow (#146#149) — in flight on `milestone/m10-studio` |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does
@@ -176,11 +178,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
```
develop
└── milestone/m8-consolidation # integration branch
└── milestone/m10-studio # M10 integration branch
└── feat/<issue>-<slug> # one issue per branch
```
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #146#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
## Licence
+11 -2
View File
@@ -13,6 +13,7 @@ struct AppEnvironment: Sendable {
let runner: ArgyllRunner
let cupsService: CupsService
let historyStore: VerificationHistoryStore
let mediaStore: MediaLibraryStore
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -20,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)
}
@@ -35,12 +40,14 @@ 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,
binaryDir: cupsDir),
historyStore: VerificationHistoryStore()
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore()
)
}
}
@@ -74,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)
+255
View File
@@ -0,0 +1,255 @@
import SwiftUI
import ICCeryCore
/// `#saveMediaRecipeDialog` capture the current printer + paper +
/// ink + `.cal` bound to the selected preset (issue #146). Clones
/// `SavePresetDialog` chrome; names render via `Text` only (#114).
struct SaveMediaRecipeDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
private var printerCaption: String {
let queue = printSession.selectedPrinter
guard !queue.isEmpty else { return "None" }
let display = printSession.printers
.first { $0.name == queue }?.displayName ?? queue
return "\(display) (\(queue))"
}
private func captureRow(
_ label: String, value: String, identifier: String
) -> some View {
HStack {
Text(label).foregroundStyle(.secondary)
Spacer()
Text(value)
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.accessibilityIdentifier(identifier)
}
}
private var saveDisabled: Bool {
media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty
|| media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty
|| media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty
|| media.captureColourSpaceMismatch
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
TextField("Name", text: $media.saveMediaName)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaName")
TextField("Notes (optional)", text: $media.saveMediaNotes)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaNotes")
TextField("Paper", text: $media.saveMediaPaper)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaPaper")
TextField("Ink set", text: $media.saveMediaInk)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaInk")
captureRow("Printer", value: printerCaption,
identifier: "saveMediaPrinter")
captureRow("Preset",
value: workflow.selectedPreset?.name ?? "No preset",
identifier: "saveMediaPreset")
captureRow("Colour space",
value: workflow.colourSpace.rawValue.uppercased(),
identifier: "saveMediaColourSpace")
captureRow("Calibration",
value: workflow.profile.calibrationFile.isEmpty
? "None" : workflow.profile.calibrationFile,
identifier: "saveMediaCal")
Toggle("Apply calibration to profile",
isOn: $media.saveMediaApplyCal)
.disabled(!media.calApplyable)
.accessibilityIdentifier("saveMediaApplyCal")
if media.captureColourSpaceMismatch {
Text("Colour space does not match the selected preset.")
.font(.caption).foregroundStyle(.orange)
}
if let error = media.saveMediaError {
Text(error).font(.caption).foregroundStyle(.orange)
}
HStack {
Spacer()
Button("Cancel") { workflow.showingSaveMedia = false }
.accessibilityIdentifier("btnCloseSaveMediaDialog")
Button("Save") {
Task {
if await media.captureFromSession() {
workflow.showingSaveMedia = false
}
}
}
.disabled(saveDisabled)
.accessibilityIdentifier("btnConfirmSaveMedia")
}
}
.padding(20)
.frame(width: 380)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("saveMediaRecipeDialog")
}
}
/// `#manageMediaDialog` list, apply, delete, capture (issue #146).
/// `List`, not `Table` macOS 12 target. Clones `ManagePresetsDialog`.
struct ManageMediaDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var media: MediaLibraryViewModel
@State private var selection: String?
@State private var pendingDelete: MediaRecipe?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._media = ObservedObject(wrappedValue: workflow.media)
}
private func presetCaption(for recipe: MediaRecipe) -> String {
workflow.presets.first { $0.id == recipe.presetID }?.name
?? "Missing preset"
}
private func calCaption(for recipe: MediaRecipe) -> String {
if media.staleReasons[recipe.id]?.contains(.calibration) == true {
return "Stale"
}
if let days = media.calAgeDays[recipe.id] {
return "Cal \(days)d"
}
return "No cal"
}
private func applyAndDismiss(_ recipe: MediaRecipe) {
Task {
if await media.apply(recipe) {
workflow.showingManageMedia = false
}
}
}
private func presetMissing(_ recipe: MediaRecipe) -> Bool {
!workflow.presets.contains { $0.id == recipe.presetID }
}
private func calStale(_ recipe: MediaRecipe) -> Bool {
media.staleReasons[recipe.id]?.contains(.calibration) == true
}
@ViewBuilder
private func row(_ recipe: MediaRecipe) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(recipe.name).foregroundStyle(Theme.text)
Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 8) {
Text(presetCaption(for: recipe))
.font(.caption)
.foregroundStyle(presetMissing(recipe) ? .orange : .secondary)
Text(calCaption(for: recipe))
.font(.caption)
.foregroundStyle(calStale(recipe) ? .orange : .secondary)
}
}
Spacer()
Button("Apply") { applyAndDismiss(recipe) }
.accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)")
Button("Delete", role: .destructive) {
pendingDelete = recipe
}
.accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("mediaRow-\(recipe.id)")
.tag(recipe.id)
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture(count: 2).onEnded { applyAndDismiss(recipe) }
)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text)
List(selection: $selection) {
if media.recipes.isEmpty {
Text("No media recipes yet. Capture the current printer, paper and preset.")
.font(.callout).foregroundStyle(.secondary)
.accessibilityIdentifier("mediaLibraryEmpty")
}
ForEach(media.recipes) { recipe in
row(recipe)
}
}
.accessibilityIdentifier("mediaLibraryList")
.frame(minHeight: 260)
HStack {
Button("Apply selected") {
if let id = selection,
let recipe = media.recipes.first(where: { $0.id == id }) {
applyAndDismiss(recipe)
}
}
.disabled(selection == nil)
.keyboardShortcut(.defaultAction)
.accessibilityIdentifier("btnMediaLibraryApply")
Button("Capture current…") {
media.captureAfterManageDismiss = true
workflow.showingManageMedia = false
}
.accessibilityIdentifier("btnMediaLibraryCaptureFromManage")
Spacer()
Button("Close") { workflow.showingManageMedia = false }
.accessibilityIdentifier("btnCloseManageMediaDialog")
}
}
.padding(20)
.frame(width: 640)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("manageMediaDialog")
.onAppear {
media.reload()
media.refreshStaleness()
}
.alert(
"Delete media recipe?",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
presenting: pendingDelete
) { recipe in
Button("Cancel", role: .cancel) { pendingDelete = nil }
Button("Delete", role: .destructive) {
media.delete(recipe)
pendingDelete = nil
}
} message: { recipe in
Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.")
}
}
}
+432
View File
@@ -0,0 +1,432 @@
import Combine
import Foundation
import ICCeryCore
/// Media recipe library: capture / apply / staleness for issue #146.
///
/// A recipe binds a CUPS queue + paper + ink + `.cal` to a
/// `ProfilingPreset`. Applying a recipe goes through the existing
/// `applyPreset` (#82) path there is no second Stage 1 form. Paper
/// and ink are library metadata only; they are never written to the
/// targen label fields.
@MainActor
final class MediaLibraryViewModel: ObservableObject {
/// Why a recipe row is flagged stale.
struct StaleReason: OptionSet {
let rawValue: Int
static let calibration = StaleReason(rawValue: 1 << 0)
static let printer = StaleReason(rawValue: 1 << 1)
}
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let store: MediaLibraryStore
private var cancellables = Set<AnyCancellable>()
@Published var recipes: [MediaRecipe] = []
/// Sidebar picker selection; `"none"` = no media recipe. Written
/// only on a successful apply so a failed apply snaps back.
@Published var selectedRecipeID = "none"
/// `recipe.id` stale reasons for the sidebar badge / manage sheet.
@Published var staleReasons: [String: StaleReason] = [:]
/// `recipe.id` whole days since the bound `.cal` was created.
@Published var calAgeDays: [String: Int] = [:]
// Save-sheet state (mirrors savePresetName/savePresetDesc).
@Published var saveMediaName = ""
@Published var saveMediaNotes = ""
@Published var saveMediaPaper = ""
@Published var saveMediaInk = ""
@Published var saveMediaApplyCal = false
/// Inline caption inside the capture sheet (no a11y id roster complete).
@Published var saveMediaError: String?
/// Pure flow flag the manage sheet's "Capture current" asks the
/// sheet's `onDismiss` to open the capture sheet, avoiding a
/// present-while-dismissing race.
var captureAfterManageDismiss = false
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
self.store = environment.mediaStore
reload()
refreshStaleness()
// The library needs queues enumerated at launch so Capture can
// enable and the not-installed badge is computable; Stage 2 only
// enumerates when a printtarg manifest exists.
if workflow.print.printers.isEmpty {
workflow.print.refreshPrinters()
}
NotificationCenter.default
.publisher(for: SettingsStore.settingsDidChange)
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
workflow.print.$printers
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
workflow.print.$selectedPrinter
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
}
deinit { cancellables.removeAll() }
// MARK: - Load / corrupt
func reload() {
Task { await reloadAsync() }
}
func reloadAsync() async {
do {
recipes = try await store.load()
} catch {
// Corrupt-file policy: keep the file, keep the cache,
// persistent warning; the picker falls back to "none".
workflow.wizard.showNotice(
"Media library is unreadable — the existing file was kept.",
kind: .warning,
autoHideAfter: nil
)
if recipes.isEmpty { selectedRecipeID = "none" }
}
}
// MARK: - Selection / apply
/// Sidebar `mediaSelect` binding. `"none"` clears the selection
/// without resetting any Stage 1/2/4 field it is not "reset to
/// factory".
func selectRecipe(_ id: String) {
if id == "none" {
selectedRecipeID = "none"
return
}
guard let recipe = recipes.first(where: { $0.id == id }) else { return }
Task { _ = await apply(recipe) }
}
/// The single apply path sidebar picker, manage-row Apply, and
/// the manage footer all funnel here.
///
/// Returns `false` when any bound resource is unresolved (missing
/// preset, colour-space mismatch, queue absent, missing/unparseable
/// `.cal`) so the manage sheet stays open and the picker reverts.
/// A `CAL_`-blocked calibration counts as applied (`true` success
/// with warning; the refusal is permanent so re-clicking can't help).
@discardableResult
func apply(_ recipe: MediaRecipe) async -> Bool {
guard let r = try? recipe.validated() else {
workflow.wizard.showNotice(
"Media recipe is invalid — not applied.", kind: .error)
return false
}
guard let preset = environment.presetStore.all()
.first(where: { $0.id == r.presetID })
else {
workflow.wizard.showNotice(
"Preset \(r.presetID) no longer exists — recipe not applied.",
kind: .error)
return false
}
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
workflow.wizard.showNotice(
"Recipe colour space does not match its preset — not applied.",
kind: .error)
return false
}
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
workflow.applyPreset(preset)
// Literal per issue: displayName, not the queue id.
workflow.wizard.printerName = r.printerDisplayName
var succeeded = true
// Queue: enumerate fresh via the session's serialized path
// listPrinters uses fixed process ids, so an overlapping
// enumeration would throw duplicateID. An empty result is a
// valid list.
if let queues = await workflow.print.enumeratePrinters() {
if queues.contains(where: { $0.name == r.printerID }) {
workflow.print.selectedPrinter = r.printerID
await workflow.print.reloadSelectedCapabilities()
} else {
workflow.wizard.showNotice(
"Printer \(r.printerDisplayName) is not installed.",
kind: .warning)
succeeded = false
}
} else {
workflow.wizard.showNotice(
"Could not enumerate printers — queue left unchanged.",
kind: .warning)
succeeded = false
}
// Calibration the recipe is authoritative and runs after
// applyPreset so the preset's own cal fields don't win.
let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? ""
let calStem = URL(fileURLWithPath: calPath)
.deletingPathExtension().lastPathComponent
let blocked = r.applyCalibration && !calPath.isEmpty
&& (CalibrationIdentity.isCalibration(calStem)
|| CalibrationIdentity.isCalibration(workflow.wizard.basename))
if blocked {
// Literal CAL_ refusal on both names (decision 1): keep the
// path for display but never let `printtarg -K` see it.
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
selectedRecipeID = r.id
refreshStaleness()
workflow.wizard.showNotice(
"Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.",
kind: .warning,
autoHideAfter: nil)
return true
}
if r.applyCalibration && !calPath.isEmpty {
guard FileManager.default.fileExists(atPath: calPath) else {
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
workflow.wizard.showNotice(
"Calibration file is missing: \(calPath)", kind: .error)
refreshStaleness()
return false
}
do {
let staleDays = environment.settingsStore.load().calibrationStaleDays
let calStore = CalibrationStore(staleDays: staleDays)
try await calStore.load(url: URL(fileURLWithPath: calPath))
workflow.profile.calibrationFile = calPath
workflow.profile.applyCalibration = true
// Age check only the .cal DESCRIPTOR is free text, not
// a queue id, so a name compare false-positives.
if await calStore.isStale() {
workflow.wizard.showNotice(
"Applied \(r.name) — calibration is stale.",
kind: .warning)
}
} catch {
workflow.profile.applyCalibration = false
workflow.wizard.showNotice(
"Could not load calibration: \(error.localizedDescription)",
kind: .error)
refreshStaleness()
return false
}
} else {
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
}
if succeeded {
selectedRecipeID = r.id
workflow.wizard.showNotice("Applied \(r.name)")
}
refreshStaleness()
return succeeded
}
// MARK: - Capture
/// Whether the live `profile.calibrationFile` may be applied:
/// non-empty, not a `CAL_` stem, and present on disk.
var calApplyable: Bool {
let path = workflow.profile.calibrationFile
guard !path.isEmpty else { return false }
let stem = URL(fileURLWithPath: path)
.deletingPathExtension().lastPathComponent
guard !CalibrationIdentity.isCalibration(stem) else { return false }
return FileManager.default.fileExists(atPath: path)
}
/// A bound preset whose colour space disagrees with the live form
/// the sheet shows the mismatch caption and disables Save.
var captureColourSpaceMismatch: Bool {
guard let preset = workflow.selectedPreset else { return false }
return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue
}
/// Opens the capture sheet, prefilled from the selected recipe else
/// the most recently captured one ("last recipe or empty").
func beginCapture() {
let source = recipes.first(where: { $0.id == selectedRecipeID })
?? recipes.last
saveMediaPaper = source?.paperName ?? ""
saveMediaInk = source?.inkSet ?? ""
saveMediaName = ""
saveMediaNotes = ""
saveMediaError = nil
saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable
if workflow.print.printers.isEmpty {
workflow.print.refreshPrinters()
}
workflow.showingSaveMedia = true
}
/// Save button the sheet closes only on `true`.
func captureFromSession() async -> Bool {
let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines)
let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines)
let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else {
saveMediaError = "Name, paper and ink are required."
return false
}
let queue = workflow.print.selectedPrinter
guard !queue.isEmpty else {
saveMediaError = "Select a printer in Stage 2 first."
return false
}
// Preset binding: the selected preset when its colour space
// matches the live form; otherwise auto-snapshot the live form
// as a custom preset (decision 2).
let presetID: String
if let bound = workflow.selectedPreset {
guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else {
saveMediaError = "Colour space does not match the selected preset."
return false
}
presetID = bound.id
} else {
let snapshot = ProfilingPreset(
id: "custom-\(UUID().uuidString.lowercased())",
name: name,
description: "Auto-saved for media recipe",
targen: workflow.buildTargenConfig(),
printtarg: workflow.buildPrinttargConfig(),
colprof: workflow.profile.buildColprofConfig(),
calibrationFile: nil,
applyCalibration: nil
)
do {
try environment.presetStore.saveCustom(snapshot)
workflow.reloadPresets()
workflow.selectedPresetID = snapshot.id
} catch {
saveMediaError = "Could not save preset: \(error.localizedDescription)"
return false
}
presetID = snapshot.id
}
// The cal path is stored verbatim; applyCalibration is forced
// off for CAL_/missing paths via calApplyable.
let calPath = workflow.profile.calibrationFile
let printer = workflow.print.printers.first { $0.name == queue }
let now = Date()
let recipe = MediaRecipe(
id: "recipe-\(UUID().uuidString.lowercased())",
name: name,
notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines),
printerID: queue,
printerDisplayName: printer?.displayName ?? queue,
paperName: paper,
driverMediaType: workflow.print.selectedMediaType,
inkSet: ink,
colourSpace: workflow.colourSpace.rawValue,
presetID: presetID,
calibrationURL: calPath.isEmpty ? nil : calPath,
applyCalibration: saveMediaApplyCal && calApplyable,
created: now,
updated: now
)
do {
let validated = try recipe.validated()
try await store.upsert(validated)
await reloadAsync()
selectedRecipeID = validated.id
workflow.wizard.showNotice("Media recipe saved: \(validated.name)")
return true
} catch let error as MediaLibraryStore.MediaLibraryError {
saveMediaError = error.errorDescription
return false
} catch {
saveMediaError = "Could not save: \(error.localizedDescription)"
return false
}
}
// MARK: - Delete
func delete(_ recipe: MediaRecipe) {
Task {
do {
try await store.delete(id: recipe.id)
await reloadAsync()
if selectedRecipeID == recipe.id {
selectedRecipeID = "none"
}
} catch {
workflow.wizard.showNotice(
"Could not delete: \(error.localizedDescription)",
kind: .error)
}
}
}
// MARK: - Staleness
func refreshStaleness() {
Task { await refreshStalenessAsync() }
}
/// Recomputes `staleReasons` + `calAgeDays` for every recipe.
///
/// `.printer` fires only when `printerID` is absent from a
/// **non-empty** enumerated queue list an un-enumerated list is
/// indeterminate, not stale (decision 5). `.calibration` is a pure
/// age check (`isStale()` with no `comparedTo:`) the `.cal`
/// DESCRIPTOR is free text, not a queue id.
func refreshStalenessAsync() async {
let staleDays = environment.settingsStore.load().calibrationStaleDays
let queues = workflow.print.printers
let now = Date()
let calStore = CalibrationStore(staleDays: staleDays)
var reasons: [String: StaleReason] = [:]
var ages: [String: Int] = [:]
for r in recipes {
var flags: StaleReason = []
if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) {
flags.insert(.printer)
}
if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces),
!raw.isEmpty,
FileManager.default.fileExists(atPath: raw),
(try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil,
let created = await calStore.data?.created {
ages[r.id] = Calendar.current
.dateComponents([.day], from: created, to: now).day ?? 0
if await calStore.isStale() {
flags.insert(.calibration)
}
}
if !flags.isEmpty { reasons[r.id] = flags }
}
staleReasons = reasons
calAgeDays = ages
}
// MARK: - Manage sheet flow
/// Called from the manage sheet's `onDismiss`. A deferred capture
/// request opens the save sheet only now, after the manage sheet has
/// fully dismissed.
func manageDismissed() {
if captureAfterManageDismiss {
captureAfterManageDismiss = false
beginCapture()
}
}
}
@@ -24,24 +24,41 @@ final class PrintSessionViewModel: ObservableObject {
self.environment = environment
}
private var printerEnumTask: Task<[Printer]?, Never>?
func refreshPrinters() {
let cups = environment.cupsService
Task { @MainActor in
Task { @MainActor in _ = await enumeratePrinters() }
}
/// Serialized queue enumeration `listPrinters` uses fixed process
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
/// callers coalesce onto the in-flight task (#146).
@discardableResult
func enumeratePrinters() async -> [Printer]? {
if let pending = printerEnumTask { return await pending.value }
let task = Task { @MainActor [weak self] () -> [Printer]? in
guard let self else { return nil }
do {
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
let list = try await self.environment.cupsService.listPrinters()
self.printers = list
if !list.contains(where: { $0.name == self.selectedPrinter }) {
self.selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await reloadSelectedCapabilities()
await self.reloadSelectedCapabilities()
return list
} catch {
printNotice = Notice(
self.printNotice = Notice(
kind: .error,
text: "Could not list printers: \(error.localizedDescription)"
)
return nil
}
}
printerEnumTask = task
let result = await task.value
printerEnumTask = nil
return result
}
func reloadSelectedCapabilities() async {
+19
View File
@@ -56,6 +56,25 @@ struct RootView: View {
.sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow)
}
// Media library sheets live on RootView, never inside the
// 270 pt sidebar column (#146).
.sheet(isPresented: $workflow.showingSaveMedia) {
SaveMediaRecipeDialog(workflow: workflow)
}
.sheet(
isPresented: $workflow.showingManageMedia,
onDismiss: { workflow.media.manageDismissed() }
) {
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 }
}
+1 -1
View File
@@ -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)
+76
View File
@@ -9,6 +9,9 @@ struct SidebarView: View {
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@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
@@ -22,6 +25,9 @@ struct SidebarView: View {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
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
@@ -90,6 +96,56 @@ struct SidebarView: View {
.padding(.horizontal, 12)
.padding(.bottom, 8)
// Media library (`#mediaSelect`) issue #146. Selection
// applies the recipe immediately, like presets; names render
// via Text only (#114). Never reuses `presetSelect` (#137).
Picker("Media", selection: Binding(
get: { media.selectedRecipeID },
set: { media.selectRecipe($0) }
)) {
Text("No media recipe").tag("none")
ForEach(media.recipes) { recipe in
Text(recipe.name).tag(recipe.id)
}
}
.pickerStyle(.menu)
.accessibilityIdentifier("mediaSelect")
.padding(.horizontal, 12)
.padding(.vertical, 8)
.helpOverlay(
"Saved printer + paper + ink + .cal bound to a preset.",
showing: $showingAllHelp)
if let reasons = media.staleReasons[media.selectedRecipeID],
!reasons.isEmpty {
Text(reasons.contains(.printer)
? "Printer not installed" : "Calibration stale")
.font(.caption)
.foregroundStyle(.orange)
.padding(.horizontal, 12)
.accessibilityIdentifier("mediaRecipeStale")
.helpOverlay(
"Re-run Stage 0 or pick a different recipe.",
showing: $showingAllHelp)
}
HStack(spacing: 8) {
Button("Capture") { media.beginCapture() }
.disabled(printSession.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnMediaLibraryCapture")
.helpOverlay(
"Select a printer in Stage 2 first",
showing: $showingAllHelp)
Button("Manage") { workflow.showingManageMedia = true }
.accessibilityIdentifier("btnMediaLibraryManage")
.helpOverlay(
"Apply or delete saved media recipes.",
showing: $showingAllHelp)
Spacer()
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
// Calibrate Printer (`#btnCalibratePrinter`).
Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
@@ -107,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)
+360
View File
@@ -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)
}
}
+420
View File
@@ -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 0100 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: "\"\""))\""
}
}
@@ -97,6 +97,16 @@ final class TargetWorkflowViewModel: ObservableObject {
@Published var savePresetName = ""
@Published var savePresetDesc = ""
// MARK: - Media library (issue #146)
@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
@@ -107,6 +117,10 @@ final class TargetWorkflowViewModel: ObservableObject {
@Published var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session.
@Published var print: PrintSessionViewModel!
/// 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
@@ -126,6 +140,14 @@ final class TargetWorkflowViewModel: ObservableObject {
profile: self.profile,
environment: environment
)
self.media = MediaLibraryViewModel(
workflow: self,
environment: environment
)
self.spotRead = SpotReadViewModel(
workflow: self,
environment: environment
)
reloadPresets()
}
@@ -0,0 +1,96 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #146 `MediaLibraryStore` persistence contract.
final class MediaLibraryStoreTests: XCTestCase {
private var url: URL!
override func setUp() {
url = FileManager.default.temporaryDirectory
.appendingPathComponent("media-lib-\(UUID().uuidString).json")
}
override func tearDown() {
try? FileManager.default.removeItem(at: url)
url = nil
}
private func recipe(id: String, name: String = "R") -> MediaRecipe {
MediaRecipe(
id: id, name: name, printerID: "q",
colourSpace: "rgb", presetID: "preset-std-rgb")
}
func testRoundTrip() async throws {
let store = MediaLibraryStore(url: url)
let r = recipe(id: "recipe-1", name: "Epson Rag")
try await store.upsert(r)
let loaded = try await store.load()
XCTAssertEqual(loaded, [r])
}
func testCorruptFileThrowsAndPreservesBytes() async throws {
try "not json".write(to: url, atomically: true, encoding: .utf8)
let before = try Data(contentsOf: url)
let store = MediaLibraryStore(url: url)
await assertAsyncThrows(expectedType: DecodingError.self) {
try await store.load()
}
// upsert must also propagate a corrupt file is never wiped.
await assertAsyncThrows(expectedType: DecodingError.self) {
try await store.upsert(recipe(id: "x"))
}
XCTAssertEqual(try Data(contentsOf: url), before)
}
func testDelete() async throws {
let store = MediaLibraryStore(url: url)
try await store.upsert(recipe(id: "a"))
try await store.upsert(recipe(id: "b"))
let removed = try await store.delete(id: "a")
XCTAssertTrue(removed)
let remaining = try await store.load().map(\.id)
XCTAssertEqual(remaining, ["b"])
let again = try await store.delete(id: "a")
XCTAssertFalse(again)
}
func testCapacityReached() async throws {
let store = MediaLibraryStore(url: url, capacity: 2)
try await store.upsert(recipe(id: "1"))
try await store.upsert(recipe(id: "2"))
await assertAsyncThrows(
expectedType: MediaLibraryStore.MediaLibraryError.self
) {
try await store.upsert(recipe(id: "3"))
} errorHandler: {
XCTAssertEqual($0, .capacityReached(2))
}
let stored = try await store.load()
XCTAssertEqual(stored.count, 2)
}
func testUpsertPreservesCreatedBumpsUpdated() async throws {
let store = MediaLibraryStore(url: url)
var r = recipe(id: "recipe-1")
r.created = Date(timeIntervalSince1970: 1_000_000)
r.updated = Date(timeIntervalSince1970: 1_000_000)
try await store.upsert(r)
var edit = r
edit.name = "Renamed"
edit.updated = Date(timeIntervalSince1970: 2_000_000)
try await store.upsert(edit)
let loaded = try await store.load()
XCTAssertEqual(loaded.count, 1)
XCTAssertEqual(loaded[0].name, "Renamed")
XCTAssertEqual(loaded[0].created, r.created)
XCTAssertGreaterThan(loaded[0].updated, r.updated)
}
}
@@ -0,0 +1,406 @@
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #146 `MediaLibraryViewModel` apply / capture / staleness
/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir.
@MainActor
final class MediaLibraryViewModelTests: XCTestCase {
// CTI3 fixture mirrored from CalibrationStoreTests.
private static let sampleCal = """
CTI3
DESCRIPTOR "Test printer"
COLOR_REP "RGB"
DEVICE_CLASS "OUTPUT"
MAX_TAC "300"
NUMBER_OF_FIELDS 5
NUMBER_OF_SETS 3
BEGIN_DATA_FORMAT
SAMPLE_ID INPUT_VALUE R G B
END_DATA_FORMAT
BEGIN_DATA
1 0 0 0 0
2 128 64 64 64
3 255 255 255 255
END_DATA
"""
/// Same fixture plus an old CREATED keyword so the age check fires.
private static let staleCal = """
CTI3
DESCRIPTOR "Test printer"
CREATED "2020-01-01T00:00:00Z"
COLOR_REP "RGB"
DEVICE_CLASS "OUTPUT"
NUMBER_OF_FIELDS 5
NUMBER_OF_SETS 3
BEGIN_DATA_FORMAT
SAMPLE_ID INPUT_VALUE R G B
END_DATA_FORMAT
BEGIN_DATA
1 0 0 0 0
2 128 64 64 64
3 255 255 255 255
END_DATA
"""
private var env: TestAppEnvironment!
private var workflow: TargetWorkflowViewModel!
private var media: MediaLibraryViewModel!
override func setUp() async throws {
env = try TestAppEnvironment.make()
try installMockCups()
workflow = TargetWorkflowViewModel(environment: env.environment)
media = workflow.media
await media.reloadAsync()
}
override func tearDown() async throws {
env?.cleanup()
env = nil
workflow = nil
media = nil
}
/// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the
/// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues:
/// `Mock_Queue` (default) and `Other_Queue`.
private func installMockCups() throws {
let bin = env.root.appendingPathComponent("cups-bin")
try FileManager.default.createDirectory(
at: bin, withIntermediateDirectories: true)
let lpstat = """
#!/bin/sh
case "$1" in
-e) printf 'Mock_Queue\\nOther_Queue\\n' ;;
-p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;;
-d) printf 'system default destination: Mock_Queue\\n' ;;
esac
exit 0
"""
let lpoptions = """
#!/bin/sh
list=0
queue=""
for arg in "$@"; do
case "$arg" in
-l) list=1 ;;
-p) ;;
*) queue="$arg" ;;
esac
done
if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: *A4 Letter\\n'
printf 'MediaType/Media Type: *Stationery Glossy\\n'
exit 0
fi
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
exit 0
"""
for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] {
let url = bin.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
}
private func writeCal(
_ contents: String = MediaLibraryViewModelTests.sampleCal,
named name: String = "recipe.cal"
) throws -> String {
let url = env.root.appendingPathComponent(name)
try contents.write(to: url, atomically: true, encoding: .utf8)
return url.path
}
private func makeRecipe(
id: String = "recipe-t1",
printerID: String = "Mock_Queue",
colourSpace: String = "rgb",
presetID: String = "preset-std-rgb",
calibrationURL: String? = nil,
applyCalibration: Bool = false
) -> MediaRecipe {
MediaRecipe(
id: id,
name: "Test Recipe",
printerID: printerID,
printerDisplayName: "Mock Queue Display",
paperName: "Rag",
inkSet: "PK",
colourSpace: colourSpace,
presetID: presetID,
calibrationURL: calibrationURL,
applyCalibration: applyCalibration)
}
private func seed(_ recipe: MediaRecipe) async throws {
try await env.environment.mediaStore.upsert(recipe)
await media.reloadAsync()
}
// MARK: - Apply
func testApplyHappyPath() async throws {
let calPath = try writeCal()
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
XCTAssertTrue(applied)
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue")
XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display")
XCTAssertTrue(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
XCTAssertEqual(media.selectedRecipeID, r.id)
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
XCTAssertEqual(
workflow.buildPrinttargConfig().calibrationFile, calPath)
}
func testApplyMissingPresetRefuses() async throws {
let r = makeRecipe(presetID: "preset-nonexistent")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(media.selectedRecipeID, "none")
XCTAssertEqual(workflow.selectedPresetID, "none")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("no longer exists") == true)
}
func testApplyColourSpaceMismatchRefuses() async throws {
let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(media.selectedRecipeID, "none")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("colour space") == true)
}
func testApplyMissingCalFileFails() async throws {
let missing = env.root.appendingPathComponent("gone.cal").path
let r = makeRecipe(
calibrationURL: missing, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, missing)
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
XCTAssertEqual(media.selectedRecipeID, "none")
}
func testApplyCalPrefixedCalCannotArmK() async throws {
let calPath = try writeCal(named: "CAL_target.cal")
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
// Success with warning the refusal is permanent, re-clicking
// cannot unstick it (decision 6).
XCTAssertTrue(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
XCTAssertEqual(media.selectedRecipeID, r.id)
XCTAssertEqual(workflow.wizard.notice?.kind, .warning)
}
func testApplyLiveCalBasenameBlocks() async throws {
let calPath = try writeCal()
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
workflow.wizard.basename = "CAL_live"
let applied = await media.apply(r)
XCTAssertTrue(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
}
func testApplyMissingQueueLeavesQueueUntouched() async throws {
workflow.print.selectedPrinter = "Other_Queue"
let r = makeRecipe(printerID: "No_Such_Queue")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("is not installed") == true)
XCTAssertEqual(media.selectedRecipeID, "none")
}
// MARK: - Capture
func testCaptureCopiesPrinterAndPreset() async throws {
workflow.print.printers = [
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "preset-std-rgb"
media.saveMediaName = "My Recipe"
media.saveMediaPaper = "Rag"
media.saveMediaInk = "PK"
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored.count, 1)
XCTAssertEqual(stored[0].printerID, "Mock_Queue")
XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display")
XCTAssertEqual(stored[0].presetID, "preset-std-rgb")
XCTAssertEqual(stored[0].colourSpace, "rgb")
XCTAssertEqual(media.selectedRecipeID, stored[0].id)
}
func testCaptureNoPresetAutoSnapshots() async throws {
workflow.print.printers = [
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "none"
media.saveMediaName = "Snap"
media.saveMediaPaper = "Rag"
media.saveMediaInk = "MK"
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored.count, 1)
XCTAssertTrue(stored[0].presetID.hasPrefix("custom-"))
XCTAssertTrue(
env.environment.presetStore.customs()
.contains { $0.id == stored[0].presetID })
XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID)
}
func testCaptureRequiresPrinter() async {
workflow.print.selectedPrinter = ""
media.saveMediaName = "n"
media.saveMediaPaper = "p"
media.saveMediaInk = "i"
let saved = await media.captureFromSession()
XCTAssertFalse(saved)
XCTAssertNotNil(media.saveMediaError)
}
func testCaptureForcesOffCalToggleForCalFile() async throws {
let calPath = try writeCal(named: "CAL_target.cal")
workflow.profile.calibrationFile = calPath
workflow.profile.applyCalibration = true
workflow.print.printers = [Printer(name: "Mock_Queue")]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "preset-std-rgb"
media.saveMediaName = "n"
media.saveMediaPaper = "p"
media.saveMediaInk = "i"
media.saveMediaApplyCal = true // forced off by calApplyable
XCTAssertFalse(media.calApplyable)
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim
XCTAssertFalse(stored[0].applyCalibration)
}
// MARK: - Staleness
func testStaleCalFlagged() async throws {
let calPath = try writeCal(
MediaLibraryViewModelTests.staleCal, named: "old.cal")
let r = makeRecipe(calibrationURL: calPath)
try await seed(r)
workflow.print.printers = [Printer(name: "Mock_Queue")]
await media.refreshStalenessAsync()
XCTAssertTrue(
media.staleReasons[r.id]?.contains(.calibration) == true)
XCTAssertNotNil(media.calAgeDays[r.id])
}
func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws {
let r = makeRecipe(printerID: "No_Such_Queue")
try await seed(r)
// Un-enumerated list is indeterminate no flag.
workflow.print.printers = []
await media.refreshStalenessAsync()
XCTAssertNil(media.staleReasons[r.id])
// Absent from a non-empty list .printer.
workflow.print.printers = [Printer(name: "Other_Queue")]
await media.refreshStalenessAsync()
XCTAssertTrue(
media.staleReasons[r.id]?.contains(.printer) == true)
// Present but unselected no flag.
workflow.print.printers = [
Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"),
]
workflow.print.selectedPrinter = "Other_Queue"
await media.refreshStalenessAsync()
XCTAssertNil(media.staleReasons[r.id])
}
// MARK: - Delete
func testDeleteResetsSelection() async throws {
let r = makeRecipe()
try await seed(r)
media.selectedRecipeID = r.id
media.delete(r)
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(media.selectedRecipeID, "none")
let stored = await env.environment.mediaStore.all()
XCTAssertTrue(stored.isEmpty)
}
// MARK: - Corrupt library
func testCorruptLibraryKeepsFileAndWarns() async throws {
// Fresh environment so the store's `loaded` flag is still false.
let env2 = try TestAppEnvironment.make()
defer { env2.cleanup() }
try "garbage".write(
to: env2.mediaLibraryURL, atomically: true, encoding: .utf8)
let workflow2 = TargetWorkflowViewModel(
environment: env2.environment)
await workflow2.media.reloadAsync()
XCTAssertTrue(workflow2.media.recipes.isEmpty)
XCTAssertEqual(workflow2.media.selectedRecipeID, "none")
XCTAssertEqual(workflow2.wizard.notice?.kind, .warning)
XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL),
"garbage".data(using: .utf8))
}
}
@@ -0,0 +1,126 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #146 `MediaRecipe` Codable + validation contract.
final class MediaRecipeTests: XCTestCase {
private func makeRecipe() -> MediaRecipe {
MediaRecipe(
id: "recipe-abc",
name: "Epson Rag",
notes: "notes",
printerID: "epson_p900",
printerDisplayName: "Epson SureColor P900",
paperName: "Rag Photographique",
driverMediaType: "PhotographicGlossy",
inkSet: "PK",
colourSpace: "rgb",
presetID: "preset-std-rgb",
calibrationURL: "/tmp/prof.cal",
applyCalibration: true,
created: Date(timeIntervalSince1970: 1_700_000_000),
updated: Date(timeIntervalSince1970: 1_700_000_100)
)
}
func testRoundTripSnakeCase() throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(makeRecipe())
let object = try JSONSerialization.jsonObject(with: data) as! [String: Any]
for key in [
"printer_id", "printer_display_name", "paper_name",
"driver_media_type", "ink_set", "colour_space", "preset_id",
"calibration_url", "apply_calibration", "created", "updated",
"id", "name", "notes",
] {
XCTAssertNotNil(object[key], "missing key \(key)")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let decoded = try decoder.decode(MediaRecipe.self, from: data)
XCTAssertEqual(decoded, makeRecipe())
}
func testMissingRequiredKeyThrows() {
for key in ["id", "name", "printer_id", "colour_space", "preset_id"] {
var dict: [String: Any] = [
"id": "r1", "name": "n", "printer_id": "q",
"colour_space": "rgb", "preset_id": "p",
]
dict.removeValue(forKey: key)
let data = try! JSONSerialization.data(withJSONObject: dict)
XCTAssertThrowsError(
try JSONDecoder().decode(MediaRecipe.self, from: data),
"expected throw without \(key)")
}
}
func testUnknownKeysIgnored() throws {
let dict: [String: Any] = [
"id": "r1", "name": "n", "printer_id": "q",
"colour_space": "rgb", "preset_id": "p",
"future_field": "ignored",
]
let data = try JSONSerialization.data(withJSONObject: dict)
let recipe = try JSONDecoder().decode(MediaRecipe.self, from: data)
XCTAssertEqual(recipe.id, "r1")
XCTAssertEqual(recipe.notes, "")
XCTAssertFalse(recipe.applyCalibration)
}
func testValidatedGoldens() {
var r = makeRecipe()
r.name = " "
XCTAssertThrowsError(try r.validated()) {
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyName)
}
r = makeRecipe()
r.printerID = ""
XCTAssertThrowsError(try r.validated()) {
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPrinterID)
}
r = makeRecipe()
r.presetID = ""
XCTAssertThrowsError(try r.validated()) {
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPresetID)
}
r = makeRecipe()
r.colourSpace = "lab"
XCTAssertThrowsError(try r.validated()) {
XCTAssertEqual(
$0 as? MediaRecipe.ValidationError, .invalidColourSpace("lab"))
}
for bad in ["../evil.cal", "rel/path.cal", "/tmp/a\0b.cal"] {
r = makeRecipe()
r.calibrationURL = bad
XCTAssertThrowsError(try r.validated(), "expected throw for \(bad)") {
XCTAssertEqual(
$0 as? MediaRecipe.ValidationError,
.invalidCalibrationURL(bad))
}
}
}
func testColourSpaceNormalisedToLowercase() throws {
var r = makeRecipe()
r.colourSpace = "RGB"
let validated = try r.validated()
XCTAssertEqual(validated.colourSpace, "rgb")
}
func testCalPrefixedCalNameIsSchemaValid() throws {
var r = makeRecipe()
// CAL_ refusal is an apply-time policy, not a schema error.
r.calibrationURL = "/tmp/CAL_target.cal"
XCTAssertNoThrow(try r.validated())
}
}
@@ -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)
}
}
+18 -2
View File
@@ -19,10 +19,21 @@ struct TestAppEnvironment {
var historyURL: URL {
root.appendingPathComponent("verification_history.json")
}
var mediaLibraryURL: URL {
root.appendingPathComponent("media_library.json")
}
/// 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(
@@ -41,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,
@@ -49,6 +62,9 @@ struct TestAppEnvironment {
),
historyStore: VerificationHistoryStore(
url: root.appendingPathComponent("verification_history.json")
),
mediaStore: MediaLibraryStore(
url: root.appendingPathComponent("media_library.json")
)
)
return TestAppEnvironment(root: root, environment: environment)
+48
View File
@@ -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,162 @@
import XCTest
/// Milestone 10 UI tests issue #146 media recipe library. Mock CUPS
/// binaries (`ICCERY_CUPS_BIN_DIR` `Fixtures/bin`) emit
/// `Mock_Epson_7450` / `Mock_Canon_Pro`; recipes are seeded by writing
/// `<ICCERY_TEST_ROOT>/AppData/media_library.json` before launch
/// `AppPaths` redirects app data under `ICCERY_TEST_ROOT`. All queries
/// are by identifier only ("Media" also appears in help overlays).
@MainActor
final class Milestone10MediaLibraryUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui10-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
try FileManager.default.createDirectory(
at: appData, withIntermediateDirectories: true)
// A recipe bound to a queue that is never enumerated.
let fixture = """
[
{
"id": "fixture-missing-queue",
"name": "Missing Queue Recipe",
"notes": "",
"printer_id": "No_Such_Queue",
"printer_display_name": "Missing Queue",
"paper_name": "Rag",
"ink_set": "PK",
"colour_space": "rgb",
"preset_id": "preset-std-rgb",
"calibration_url": null,
"apply_calibration": false,
"created": "2026-09-12T00:00:00Z",
"updated": "2026-09-12T00:00:00Z"
}
]
"""
try fixture.write(
to: appData.appendingPathComponent("media_library.json"),
atomically: true, encoding: .utf8)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_CUPS_BIN_DIR": binDir.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
private func launchApp() {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
}
}
private func element(_ id: String) -> XCUIElement {
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
}
private func waitForEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
let el = waitFor(id, timeout: timeout)
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if el.isEnabled { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return el
}
func testMediaPickerDoesNotReusePresetSelect() throws {
launchApp()
let preset = app.popUpButtons["presetSelect"]
XCTAssertTrue(preset.waitForExistence(timeout: 10))
let media = app.popUpButtons["mediaSelect"]
XCTAssertTrue(media.waitForExistence(timeout: 10))
}
func testCaptureRequiresNamePaperInk() throws {
launchApp()
// Capture enables once the mock CUPS enumeration selects a queue.
let capture = waitForEnabled("btnMediaLibraryCapture")
XCTAssertTrue(capture.isEnabled)
capture.click()
_ = waitFor("saveMediaRecipeDialog")
let save = element("btnConfirmSaveMedia")
XCTAssertTrue(save.exists)
XCTAssertFalse(save.isEnabled)
for (id, text) in [
("saveMediaName", "UI Recipe"),
("saveMediaPaper", "Rag"),
("saveMediaInk", "PK"),
] {
let field = element(id)
field.click()
field.typeText(text)
}
XCTAssertTrue(save.isEnabled)
}
func testManageApplyMissingPrinterShowsBanner() throws {
launchApp()
let manage = app.buttons["btnMediaLibraryManage"]
XCTAssertTrue(manage.waitForExistence(timeout: 10))
manage.click()
_ = waitFor("manageMediaDialog")
let apply = element("btnMediaLibraryApply-fixture-missing-queue")
XCTAssertTrue(apply.waitForExistence(timeout: 10))
apply.click()
let notice = waitFor("noticeText")
let text = (notice.value as? String) ?? notice.label
XCTAssertTrue(
text.contains("is not installed"),
"expected not-installed notice, got: \(text)")
// A failed apply keeps the manage sheet open and the sidebar
// picker reverts.
XCTAssertTrue(element("manageMediaDialog").exists)
XCTAssertTrue(element("mediaSelect").exists)
}
}
@@ -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()
}
}
+3 -1
View File
@@ -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:614`) 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:54119` 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)
+11
View File
@@ -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).
File diff suppressed because one or more lines are too long
+22
View File
@@ -67,3 +67,25 @@ Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JS
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
UI: `#presetSelect`, `#btnSavePresetModal``#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog``#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
## Media library (`media_library.json`)
Persisted at `{app_data}/media_library.json` — a sibling of `settings.json`, never a field inside it (issue #146). A `MediaRecipe` binds a CUPS queue + paper + ink set + optional `.cal` to a `ProfilingPreset`. Cap: 200 entries; the 201st is refused with an error, never silently evicted. Corrupt JSON → keep the file, load `[]`, persistent warning banner.
| Field | Type | Notes |
|-------|------|-------|
| `id` | string | `recipe-<uuid>`, never user-typed |
| `name`, `notes` | string | Rendered through `Text` only (#114) |
| `printer_id` | string | CUPS queue id (`lpstat -e` name) |
| `printer_display_name` | string | Human label; applied to `wizard.printerName` |
| `paper_name`, `ink_set` | string | Library metadata only — never written to targen flags |
| `driver_media_type` | string? | Last captured CUPS `media_type` (read-only) |
| `colour_space` | `"rgb"` \| `"cmyk"` | Must match the bound preset |
| `preset_id` | string | `ProfilingPreset.id` (built-in or custom) |
| `calibration_url` | string? | Absolute `.cal` path, stored verbatim |
| `apply_calibration` | bool | Forced off for `CAL_` stems or missing files |
| `created`, `updated` | iso8601 | |
Apply path: recipe → `applyPreset` (#82 mapping, no second Stage 1 form) → queue re-enumerated (`lpstat -e`) → `printer_id` absent from a non-empty list warns "not installed" and leaves the queue untouched; an empty list is indeterminate and never flags. `CAL_` bound cal **or** a live `CAL_` wizard basename forces `applyCalibration` off — `printtarg -K` can never see a `CAL_` file (literal refusal; escape hatch is rename + re-capture). Staleness: `.printer` = absent from enumerated queues; `.calibration` = bound cal `CREATED + calibration_stale_days < now`.
Capture with no preset selected auto-snapshots the live form as a `custom-` preset and binds to it. UI: `#mediaSelect` (immediate apply, `#presetSelect`-style), `#btnMediaLibraryCapture``#saveMediaRecipeDialog`, `#btnMediaLibraryManage``#manageMediaDialog` (`mediaLibraryList`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`), stale badge `#mediaRecipeStale`.