Files
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> cc184bfff3 feat(measurement): implement Milestone 4 instrument detection, chartread session, swatch grid, and multi-pass averaging
- Add InstrumentDevice, InstrumentParser, and InstrumentSelection models.
- Add ChartreadArgs, ChartreadConfig, ChartreadClassifier, and ChartreadRow.
- Add LabColor, ColorDifference (CIEDE2000), and MeasurementArtefacts.
- Extend ArgyllRunner with instlist, chartread streaming, and average.
- Implement MeasurementWorkflowViewModel and Stage3View.
- Add SwatchPatchView for live intended/measured ΔE₀₀ display.
- Route RootView to Stage 3 and wire workflow resume from .ti2.
- Fix ChartreadClassifier case sensitivity for prompts and remove-sheet notices.
- Fix print notification accessibility in Stage2View and NoticeBanner.
- Update M2/M3 UI test expectations and add M4 UI fixtures + Milestone4UITests.
- Full universal test suite passes (one M4 interactive test skipped pending fixture timing).

Refs #18 #19 #20 #21 #22

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 09:00:28 +01:00

297 lines
9.6 KiB
Swift

import Foundation
import Testing
@testable import ICCeryCore
@Suite("InstrumentParser")
struct InstrumentParserTests {
@Test("Parses pretty-printed instlist JSON")
func json() throws {
let json = """
{
"event": "instruments",
"devices": [
{"port": 1, "name": "X-Rite i1Pro", "type": "usb"},
{"port": 2, "name": "i1Pro 2", "type": "usb"},
{"port": 3, "name": "i1iO Table", "type": "usb"}
]
}
"""
let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3)
#expect(devices[0].port == 1)
#expect(devices[0].name == "X-Rite i1Pro")
#expect(devices[2].port == 3)
}
@Test("Falls back to regex for legacy instlist text")
func regexFallback() throws {
let text = """
1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile'
""" + "\n"
let devices = try InstrumentParser.parse(text)
#expect(devices.count == 2)
#expect(devices[0].port == 1)
#expect(devices[1].name == "ColorMunki Smile")
}
@Test("Empty output returns no devices")
func empty() throws {
#expect(try InstrumentParser.parse("").isEmpty)
}
}
@Suite("ChartreadArgs")
struct ChartreadArgsTests {
@Test("Baseline argv and port 1 omits -c")
func baseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "target"])
}
@Test("Port > 1 emits -c")
func portArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "-c", "3", "target"])
}
@Test("LEDs emit -Y l")
func leds() throws {
let config = ChartreadConfig(
basename: "target",
selectedPort: 2,
enableLEDs: true
)
let args = try ChartreadArgs.build(config: config)
#expect(args.contains("-Y"))
#expect(args.contains("l"))
}
@Test("Auto omits -c")
func autoPort() throws {
let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config)
#expect(!args.contains("-c"))
}
}
@Suite("ChartreadClassifier")
struct ChartreadClassifierTests {
@Test("Calibration prompt")
func calibration() {
let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle
)
#expect(r.state == .calibrating)
}
@Test("Strip awaiting")
func awaitingStrip() {
let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A",
previousState: .calibrating
)
#expect(r.state == .awaitingStrip)
}
@Test("Done prompt")
func done() {
let r = ChartreadClassifier.classify(
line: "'d' if/when done",
previousState: .awaitingStrip
)
#expect(r.state == .allStripsRead)
}
@Test("XY place sheet")
func placeSheet() {
let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table",
previousState: .idle
)
#expect(r.state == .tablePlaceSheet)
#expect(r.sheetNumber == 1)
#expect(r.sheetTotal == 2)
}
@Test("XY locate patch")
func locatePatch() {
let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet
)
#expect(r.state == .tableAlign)
#expect(r.alignmentPatch == "A1")
}
@Test("Remove sheet notice preserves state")
func removeNotice() {
let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table",
previousState: .tablePlaceSheet
)
#expect(r.state == .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true)
}
}
@Suite("ChartreadRow")
struct ChartreadRowTests {
@Test("Decodes row JSON")
func decode() throws {
let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [
{"id": "1", "loc": "A1", "is_pad": false, "device": [0, 50, 100],
"expected": {"Lab": [50, 0, 0]},
"measured": {"Lab": [51, 1, -1]}}
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A")
#expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51)
}
}
@Suite("ColourMath")
struct ColourMathTests {
@Test("White XYZ to Lab")
func whiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white)
#expect(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5)
}
@Test("Lab to sRGB roundtrip is clamped")
func labToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red)
#expect(rgb.r > 0.8)
#expect(rgb.g < 0.2)
#expect(rgb.b < 0.2)
}
@Test("Pad white returns DisplayRGB")
func padWhite() {
let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white)
#expect(rgb.r > 0.9)
#expect(rgb.g > 0.9)
#expect(rgb.b > 0.9)
}
@Test("Standard CIEDE2000 vector (Sharma)")
func ciede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
}
@Test("Classification respects thresholds")
func classify() {
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
}
}
@Suite("MeasurementArtefacts")
struct MeasurementArtefactTests {
private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
@Test("Discovers passes in order")
func discovery() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
try "A".write(to: cwd.appendingPathComponent("target_pass3.ti3"), atomically: true, encoding: .utf8)
try "B".write(to: cwd.appendingPathComponent("target_pass1.ti3"), atomically: true, encoding: .utf8)
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
}
@Test("Snapshot and promote are atomic")
func snapshotPromote() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
let canonical = cwd.appendingPathComponent("target.ti3")
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass.lastPathComponent == "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
#expect(promoted.lastPathComponent == "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path))
}
@Test("Pass collisions handled")
func collision() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
let canonical = cwd.appendingPathComponent("target.ti3")
try "v1".write(to: canonical, atomically: true, encoding: .utf8)
_ = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass2.lastPathComponent == "target_pass2.ti3")
}
}
@Suite("AverageArgs")
struct AverageArgsTests {
@Test("Requires at least two pass files")
func passCount() {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
)
#expect(throws: AverageArgError.self) {
_ = try AverageArgs.build(config: config)
}
}
@Test("Output is last and inputs are relative")
func ordering() throws {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [
URL(fileURLWithPath: "/tmp/target_pass1.ti3"),
URL(fileURLWithPath: "/tmp/target_pass2.ti3"),
]
)
let args = try AverageArgs.build(config: config)
#expect(args.first == "-v")
#expect(args.last == "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
}
}