Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9a2042bf | ||
|
|
e48c3f6840 | ||
|
|
7751701208 | ||
|
|
c539507d5d | ||
|
|
0ffcf5ea91 | ||
|
|
597cce7b60 | ||
|
|
bb4512e129 | ||
|
|
12584d156a | ||
|
|
0332a2bb4f | ||
|
|
0d98440a15 | ||
|
|
073e3aa308 |
+1
-1
@@ -27,4 +27,4 @@ ICCery.xcodeproj/
|
|||||||
Release/
|
Release/
|
||||||
notarization/
|
notarization/
|
||||||
build/
|
build/
|
||||||
docs/megaplans/
|
docs/megaplans
|
||||||
|
|||||||
@@ -79,14 +79,17 @@ public enum ArtefactProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
||||||
/// requested path is missing (#69 / issue #83).
|
/// requested path is missing (#69 / issue #83). Any other extension
|
||||||
|
/// (`.mpp`, `.txt`, …) is returned unchanged — never rewritten.
|
||||||
public static func resolveProfile(
|
public static func resolveProfile(
|
||||||
_ url: URL,
|
_ url: URL,
|
||||||
fileManager: FileManager = .default
|
fileManager: FileManager = .default
|
||||||
) -> URL {
|
) -> URL {
|
||||||
if fileManager.fileExists(atPath: url.path) { return url }
|
if fileManager.fileExists(atPath: url.path) { return url }
|
||||||
let altExt = url.pathExtension.lowercased() == "icc" ? "icm" : "icc"
|
let ext = url.pathExtension.lowercased()
|
||||||
let alt = url.deletingPathExtension().appendingPathExtension(altExt)
|
guard ext == "icc" || ext == "icm" else { return url }
|
||||||
|
let alt = url.deletingPathExtension()
|
||||||
|
.appendingPathExtension(ext == "icc" ? "icm" : "icc")
|
||||||
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
|||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
self.corrupt = corrupt
|
self.corrupt = corrupt
|
||||||
self.defaultValue = defaultValue
|
self.defaultValue = defaultValue
|
||||||
let encoder = JSONEncoder()
|
self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
encoder.dateEncodingStrategy = dateEncoding
|
|
||||||
self.encoder = encoder
|
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.dateDecodingStrategy = dateDecoding
|
decoder.dateDecodingStrategy = dateDecoding
|
||||||
self.decoder = decoder
|
self.decoder = decoder
|
||||||
@@ -75,10 +72,14 @@ public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension JSONEncoder {
|
extension JSONEncoder {
|
||||||
/// Pretty-printed, sorted-keys encoder used by preset export.
|
/// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore`
|
||||||
public static func icceryPretty() -> JSONEncoder {
|
/// and preset export.
|
||||||
|
static func icceryPretty(
|
||||||
|
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||||
|
) -> JSONEncoder {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
encoder.dateEncodingStrategy = dateEncoding
|
||||||
return encoder
|
return encoder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,9 +145,7 @@ public actor ProcessManager {
|
|||||||
)
|
)
|
||||||
let process = prepared.process
|
let process = prepared.process
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
|
||||||
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
|
||||||
|
|
||||||
children[id] = RunningChild(
|
children[id] = RunningChild(
|
||||||
process: process,
|
process: process,
|
||||||
@@ -214,9 +212,7 @@ public actor ProcessManager {
|
|||||||
let stdoutPipe = prepared.stdoutPipe
|
let stdoutPipe = prepared.stdoutPipe
|
||||||
let stderrPipe = prepared.stderrPipe
|
let stderrPipe = prepared.stderrPipe
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: true)
|
||||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Register and set up the termination hand-off before run() so
|
// Register and set up the termination hand-off before run() so
|
||||||
// a very fast exit is never missed (#50, #52).
|
// a very fast exit is never missed (#50, #52).
|
||||||
@@ -466,6 +462,18 @@ public actor ProcessManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated func logSpawn(
|
||||||
|
id: String,
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
captured: Bool
|
||||||
|
) {
|
||||||
|
let prefix = captured ? "spawn(captured)" : "spawn"
|
||||||
|
AppLogger(category: "process").debug(
|
||||||
|
"\(prefix) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// `terminationHandler` can lose a fast-exit race on a loaded host;
|
/// `terminationHandler` can lose a fast-exit race on a loaded host;
|
||||||
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
||||||
/// The handler is attached before `run()`; the wait thread starts
|
/// The handler is attached before `run()`; the wait thread starts
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ public struct CalibrationIdentity: Equatable, Sendable {
|
|||||||
|
|
||||||
/// Derive identity from the live wizard basename and the persisted
|
/// Derive identity from the live wizard basename and the persisted
|
||||||
/// original. A non-empty persisted original wins over a `CAL_` live
|
/// original. A non-empty persisted original wins over a `CAL_` live
|
||||||
/// name (Force Quit mid-calibration).
|
/// name (Force Quit mid-calibration). An empty live basename always
|
||||||
|
/// produces an empty identity — a persisted original must never
|
||||||
|
/// resurrect a target that no longer exists (#83).
|
||||||
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
||||||
if liveBasename.isEmpty && persistedOriginal.isEmpty {
|
guard !liveBasename.isEmpty else {
|
||||||
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
||||||
}
|
}
|
||||||
let original: String
|
let original: String
|
||||||
if liveBasename.hasPrefix("CAL_") {
|
if liveBasename.hasPrefix("CAL_") {
|
||||||
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
||||||
} else if liveBasename.isEmpty {
|
|
||||||
original = persistedOriginal
|
|
||||||
} else {
|
} else {
|
||||||
original = liveBasename
|
original = liveBasename
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ public actor VerificationHistoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Removes all history and updates disk.
|
/// Removes all history and updates disk.
|
||||||
|
///
|
||||||
|
/// Loads the existing history first and propagates any load error so an
|
||||||
|
/// unparseable file is never overwritten.
|
||||||
public func clear() throws {
|
public func clear() throws {
|
||||||
|
try load()
|
||||||
try write([])
|
try write([])
|
||||||
records = []
|
records = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ extension TargenConfig {
|
|||||||
neutralSteps: preset.neutralSteps,
|
neutralSteps: preset.neutralSteps,
|
||||||
neutralConcentration: preset.neutralConcentration,
|
neutralConcentration: preset.neutralConcentration,
|
||||||
preconditioningProfile: preset.preconditioningProfile,
|
preconditioningProfile: preset.preconditioningProfile,
|
||||||
ofpsHighQuality: preset.ofpsHighQuality == true ? true : nil,
|
// An explicit `false` is preserved — distinguishable from a
|
||||||
|
// missing key; `-G` is only emitted for `true` (#82).
|
||||||
|
ofpsHighQuality: preset.ofpsHighQuality,
|
||||||
ofpsAdaptation: preset.ofpsAdaptation,
|
ofpsAdaptation: preset.ofpsAdaptation,
|
||||||
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
|
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
|
||||||
totalInkLimit: preset.totalInkLimit,
|
totalInkLimit: preset.totalInkLimit,
|
||||||
@@ -109,6 +111,51 @@ extension ColprofConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-facing FWA selection for the Stage 4 form, plus the two
|
||||||
|
/// directions of `colprof_fwa` conversion centralised here so the view
|
||||||
|
/// models carry no mapping switches of their own (#82).
|
||||||
|
public enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
||||||
|
case none = "none"
|
||||||
|
case empty = ""
|
||||||
|
case D50 = "D50"
|
||||||
|
case D65 = "D65"
|
||||||
|
case custom = "custom"
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .empty: return "Bare (-f)"
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return "Custom .sp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preset `colprof_fwa` → selection. `nil`/`"none"` map to `.none`,
|
||||||
|
/// `""` to `.empty`, `D50`/`D65` case-insensitively, and any other
|
||||||
|
/// string is a custom `.sp` path.
|
||||||
|
public init(presetValue: String?) {
|
||||||
|
switch presetValue?.lowercased() {
|
||||||
|
case nil, "none": self = .none
|
||||||
|
case "": self = .empty
|
||||||
|
case "d50": self = .D50
|
||||||
|
case "d65": self = .D65
|
||||||
|
default: self = .custom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selection → `colprof_fwa` value. `.custom` returns `customPath`.
|
||||||
|
public func presetValue(customPath: String) -> String? {
|
||||||
|
switch self {
|
||||||
|
case .none: return nil
|
||||||
|
case .empty: return ""
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return customPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension PageSize {
|
extension PageSize {
|
||||||
/// `"210x297"` custom page parse used by presets (issue #82).
|
/// `"210x297"` custom page parse used by presets (issue #82).
|
||||||
public static func parseCustom(_ raw: String) -> (Double, Double)? {
|
public static func parseCustom(_ raw: String) -> (Double, Double)? {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
|||||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
||||||
| Default branch | `develop` |
|
| Default branch | `develop` |
|
||||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
||||||
| M7 | UAT-ready hardening of the v2.0 wizard paths |
|
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||||
|
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` |
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
@@ -170,11 +171,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
|
|||||||
|
|
||||||
```
|
```
|
||||||
develop
|
develop
|
||||||
└── milestone/mN-<slug> # integration only
|
└── milestone/m8-consolidation # integration branch
|
||||||
└── feat/<issue>-<slug> # one issue per 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. M7 is small; its PRs target `develop` directly. 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 #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.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -3,25 +3,6 @@ import Observation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// User-facing FWA selection for the Stage 4 form.
|
|
||||||
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
|
||||||
case none = "none"
|
|
||||||
case empty = ""
|
|
||||||
case D50 = "D50"
|
|
||||||
case D65 = "D65"
|
|
||||||
case custom = "custom"
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .none: return "None"
|
|
||||||
case .empty: return "Bare (-f)"
|
|
||||||
case .D50: return "D50"
|
|
||||||
case .D65: return "D65"
|
|
||||||
case .custom: return "Custom .sp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@@ -110,13 +91,7 @@ final class ProfileWorkflowViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fwaValue: String? {
|
var fwaValue: String? {
|
||||||
switch fwaSelection {
|
fwaSelection.presetValue(customPath: fwaCustomPath)
|
||||||
case .none: return nil
|
|
||||||
case .empty: return ""
|
|
||||||
case .D50: return "D50"
|
|
||||||
case .D65: return "D65"
|
|
||||||
case .custom: return fwaCustomPath
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preset application
|
// MARK: - Preset application
|
||||||
@@ -131,21 +106,16 @@ final class ProfileWorkflowViewModel {
|
|||||||
algorithm = config.algorithm
|
algorithm = config.algorithm
|
||||||
quality = config.quality
|
quality = config.quality
|
||||||
intent = config.intent ?? ""
|
intent = config.intent ?? ""
|
||||||
if let fwa = config.fwa {
|
fwaSelection = ColprofFwaSelection(presetValue: config.fwa)
|
||||||
switch fwa.lowercased() {
|
fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : ""
|
||||||
case "none": fwaSelection = .none
|
|
||||||
case "": fwaSelection = .empty
|
|
||||||
case "d50": fwaSelection = .D50
|
|
||||||
case "d65": fwaSelection = .D65
|
|
||||||
default:
|
|
||||||
fwaSelection = .custom
|
|
||||||
fwaCustomPath = fwa
|
|
||||||
}
|
|
||||||
}
|
|
||||||
illuminant = config.illuminant ?? ""
|
illuminant = config.illuminant ?? ""
|
||||||
observer = config.observer ?? ""
|
observer = config.observer ?? ""
|
||||||
inputViewingCond = config.inputViewingCond ?? ""
|
inputViewingCond = config.inputViewingCond ?? ""
|
||||||
outputViewingCond = config.outputViewingCond ?? ""
|
outputViewingCond = config.outputViewingCond ?? ""
|
||||||
|
profileDescription = ""
|
||||||
|
copyright = ""
|
||||||
|
applyCalibration = preset.applyCalibration == true
|
||||||
|
calibrationFile = preset.calibrationFile ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 4 form values for saving into a custom preset.
|
/// Stage 4 form values for saving into a custom preset.
|
||||||
|
|||||||
@@ -380,14 +380,18 @@ final class TargetWorkflowViewModel {
|
|||||||
func applyPreset(_ preset: ProfilingPreset) {
|
func applyPreset(_ preset: ProfilingPreset) {
|
||||||
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
|
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
|
||||||
applyTargenForm(targen)
|
applyTargenForm(targen)
|
||||||
|
// Stage 4 state (incl. calibration) is applied before Stage 2 so
|
||||||
|
// the layout config receives the preset's calibration path, not
|
||||||
|
// stale live state (#82).
|
||||||
|
profile.applyPreset(preset)
|
||||||
let printtarg = PrinttargConfig(
|
let printtarg = PrinttargConfig(
|
||||||
preset: preset,
|
preset: preset,
|
||||||
basename: wizard.basename,
|
basename: wizard.basename,
|
||||||
workingDirectory: wizard.effectiveWorkingDirectory,
|
workingDirectory: wizard.effectiveWorkingDirectory,
|
||||||
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil
|
calibrationFile: profile.applyCalibration && !profile.calibrationFile.isEmpty
|
||||||
|
? profile.calibrationFile : nil
|
||||||
)
|
)
|
||||||
applyPrinttargForm(printtarg)
|
applyPrinttargForm(printtarg)
|
||||||
profile.applyPreset(preset)
|
|
||||||
selectedPresetID = preset.id
|
selectedPresetID = preset.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,34 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
|
||||||
|
func calibrationTargenProcessId() async throws {
|
||||||
|
let testRoot = try makeTestDir()
|
||||||
|
let runner = makeRunner()
|
||||||
|
let events = ProcessManager.shared.events()
|
||||||
|
// Subscribed before spawn; the exit event is emitted before
|
||||||
|
// runCalibrationTargen returns, so this always terminates.
|
||||||
|
let sawExit = Task {
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == "targen_CAL_foo" else { continue }
|
||||||
|
if case .exit = event { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: .rgb,
|
||||||
|
steps: 21,
|
||||||
|
basename: "foo",
|
||||||
|
workingDirectory: testRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
|
#expect(url.lastPathComponent == "CAL_foo.ti1")
|
||||||
|
#expect(await sawExit.value)
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("printcal captured run creates .cal")
|
@Test("printcal captured run creates .cal")
|
||||||
func printcalProducesCal() async throws {
|
func printcalProducesCal() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
|
|||||||
@@ -125,26 +125,102 @@ struct ArtefactFilesTests {
|
|||||||
|
|
||||||
@Suite("ArtefactProbe profile resolve")
|
@Suite("ArtefactProbe profile resolve")
|
||||||
struct ArtefactProbeProfileTests {
|
struct ArtefactProbeProfileTests {
|
||||||
@Test("basename probe prefers .icm")
|
private func makeDir() throws -> URL {
|
||||||
func icmWins() throws {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
.appendingPathComponent("probe-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Basename probe matrix (#69)
|
||||||
|
|
||||||
|
@Test("basename probe: only .icc exists")
|
||||||
|
func onlyIcc() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe: only .icm exists")
|
||||||
|
func onlyIcm() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe prefers .icm")
|
||||||
|
func icmWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||||
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
||||||
#expect(url?.pathExtension == "icm")
|
#expect(url?.path == icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe: neither exists returns nil")
|
||||||
|
func neitherExists() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Explicit URL matrix (#69 / #83)
|
||||||
|
|
||||||
|
@Test("explicit existing .icc wins even when .icm exists")
|
||||||
|
func explicitIccWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("explicit existing .icm wins even when .icc exists")
|
||||||
|
func explicitIcmWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("explicit missing .icc flips to sibling .icm")
|
@Test("explicit missing .icc flips to sibling .icm")
|
||||||
func flipExtension() throws {
|
func flipExtension() throws {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = try makeDir()
|
||||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
let icc = dir.appendingPathComponent("job.icc")
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
let icm = dir.appendingPathComponent("job.icm")
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
try Data("icm".utf8).write(to: icm)
|
try Data("icm".utf8).write(to: icm)
|
||||||
let resolved = ArtefactProbe.resolveProfile(icc)
|
let resolved = ArtefactProbe.resolveProfile(icc)
|
||||||
#expect(resolved.path == icm.path)
|
#expect(resolved.path == icm.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("explicit missing .icm flips to sibling .icc")
|
||||||
|
func flipToIcc() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("explicit missing both returns the original URL")
|
||||||
|
func missingBoth() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("unrelated extension is never rewritten")
|
||||||
|
func unrelatedExtension() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let mpp = dir.appendingPathComponent("job.mpp")
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
|
||||||
|
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path)
|
||||||
|
let txt = dir.appendingPathComponent("job.txt")
|
||||||
|
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #83 — canonical `CAL_` / original-stem pairing.
|
||||||
|
@Suite("CalibrationIdentity")
|
||||||
|
struct CalibrationIdentityTests {
|
||||||
|
@Test("live foo, no persisted")
|
||||||
|
func livePlain() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live foo ignores stale persisted")
|
||||||
|
func livePlainIgnoresPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live CAL_foo, persisted foo")
|
||||||
|
func liveCalPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live CAL_foo, empty persisted strips prefix")
|
||||||
|
func liveCalNoPersist() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("persisted original wins over CAL_ live")
|
||||||
|
func persistedWins() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
||||||
|
#expect(id.originalBasename == "bar")
|
||||||
|
#expect(id.calibrationBasename == "CAL_bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty live yields empty identity even with persisted original")
|
||||||
|
func emptyLiveWithPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
|
||||||
|
#expect(id.originalBasename.isEmpty)
|
||||||
|
#expect(id.calibrationBasename.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty live, empty persisted")
|
||||||
|
func emptyLive() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename.isEmpty)
|
||||||
|
#expect(id.calibrationBasename.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("prefix is idempotent on already-prefixed input")
|
||||||
|
func alreadyPrefixed() {
|
||||||
|
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
|
||||||
|
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "CAL_foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("prefix never invents a name from empty input")
|
||||||
|
func prefixEmpty() {
|
||||||
|
#expect(CalibrationIdentity.prefix("").isEmpty)
|
||||||
|
#expect(CalibrationIdentity.strip("foo") == "foo")
|
||||||
|
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("runner process id for a calibration targen is targen_CAL_*")
|
||||||
|
func processIdMatches() {
|
||||||
|
let cal = CalibrationIdentity.prefix("foo")
|
||||||
|
#expect(ProcessID.targen(cal) == "targen_CAL_foo")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,17 @@ struct JSONFileStoreTests {
|
|||||||
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Corrupt file with replaceWithDefault returns default")
|
@Test("Missing file returns default")
|
||||||
|
func missingFileDefaults() throws {
|
||||||
|
let store = JSONFileStore<AppSettings>(
|
||||||
|
fileURL: tempURL(),
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
|
#expect(try store.load() == .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
|
||||||
func corruptDefaults() throws {
|
func corruptDefaults() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -19,7 +29,8 @@ struct JSONFileStoreTests {
|
|||||||
defaultValue: { .default }
|
defaultValue: { .default }
|
||||||
)
|
)
|
||||||
#expect(try store.load() == .default)
|
#expect(try store.load() == .default)
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(kept == "{ not json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
|
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
|
||||||
@@ -50,43 +61,24 @@ struct JSONFileStoreTests {
|
|||||||
let text = try String(contentsOf: url, encoding: .utf8)
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(text.contains("\n"))
|
#expect(text.contains("\n"))
|
||||||
#expect(text.contains("\"delta_e_good_max\""))
|
#expect(text.contains("\"delta_e_good_max\""))
|
||||||
}
|
// Lexical key sorting: ascending order of top-level keys.
|
||||||
}
|
let keys = [
|
||||||
|
"ask_before_overwrite_profile",
|
||||||
@Suite("CalibrationIdentity")
|
"calibration_stale_days",
|
||||||
struct CalibrationIdentityTests {
|
"custom_presets",
|
||||||
@Test("live foo, no persisted")
|
"default_install_location",
|
||||||
func livePlain() {
|
"delta_e_good_max",
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
"delta_e_warning_max",
|
||||||
#expect(id.originalBasename == "foo")
|
"enable_i1pro2_leds",
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
"open_color_panel_after_install",
|
||||||
}
|
]
|
||||||
|
var lastIndex = text.startIndex
|
||||||
@Test("live CAL_foo, persisted foo")
|
for key in keys {
|
||||||
func liveCalPersisted() {
|
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
Issue.record("missing or out-of-order key \(key)")
|
||||||
#expect(id.originalBasename == "foo")
|
return
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
}
|
||||||
}
|
lastIndex = range.upperBound
|
||||||
|
}
|
||||||
@Test("live CAL_foo, empty persisted strips prefix")
|
|
||||||
func liveCalNoPersist() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
|
||||||
#expect(id.originalBasename == "foo")
|
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("persisted original wins")
|
|
||||||
func persistedWins() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
|
||||||
#expect(id.originalBasename == "bar")
|
|
||||||
#expect(id.calibrationBasename == "CAL_bar")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("empty live does not invent a name")
|
|
||||||
func emptyLive() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
|
||||||
#expect(id.originalBasename.isEmpty)
|
|
||||||
#expect(id.calibrationBasename.isEmpty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,4 +287,210 @@ struct PresetMappingTests {
|
|||||||
#expect(back.colprofFwa == "D50")
|
#expect(back.colprofFwa == "D50")
|
||||||
#expect(back.greySteps == nil)
|
#expect(back.greySteps == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Full preset round-trips through all three configs with every field asserted")
|
||||||
|
func fullRoundTrip() {
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "custom-full",
|
||||||
|
name: "Full",
|
||||||
|
description: "All fields",
|
||||||
|
colourSpace: "cmyk",
|
||||||
|
patchCount: 1500,
|
||||||
|
whitePatches: 6,
|
||||||
|
blackPatches: 8,
|
||||||
|
greySteps: 9,
|
||||||
|
singleChannelSteps: 7,
|
||||||
|
neutralSteps: 4,
|
||||||
|
neutralConcentration: 0.7,
|
||||||
|
preconditioningProfile: "/tmp/pre.icm",
|
||||||
|
ofpsHighQuality: true,
|
||||||
|
ofpsAdaptation: 0.2,
|
||||||
|
fullSpreadAlgorithm: "R",
|
||||||
|
totalInkLimit: 280,
|
||||||
|
darkEmphasis: 1.3,
|
||||||
|
devicePower: 1.2,
|
||||||
|
instrument: "p3",
|
||||||
|
pageSize: "250x300",
|
||||||
|
bitDepth: 16,
|
||||||
|
dpi: 360,
|
||||||
|
randomSeed: 42,
|
||||||
|
noRandomize: false,
|
||||||
|
calibrationFile: "/tmp/a.cal",
|
||||||
|
applyCalibration: true,
|
||||||
|
colprofAlgorithm: "x",
|
||||||
|
colprofQuality: "u",
|
||||||
|
colprofIntent: "p",
|
||||||
|
colprofFwa: "D65",
|
||||||
|
colprofIlluminant: "D65",
|
||||||
|
colprofObserver: "1931_2",
|
||||||
|
colprofInputViewingCond: "D50_2",
|
||||||
|
colprofOutputViewingCond: "D65_2"
|
||||||
|
)
|
||||||
|
|
||||||
|
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
|
||||||
|
#expect(targen.colourSpace == .cmyk)
|
||||||
|
#expect(targen.patchCount == 1500)
|
||||||
|
#expect(targen.whitePatches == 6)
|
||||||
|
#expect(targen.blackPatches == 8)
|
||||||
|
#expect(targen.greySteps == 9)
|
||||||
|
#expect(targen.singleChannelSteps == 7)
|
||||||
|
#expect(targen.neutralSteps == 4)
|
||||||
|
#expect(targen.neutralConcentration == 0.7)
|
||||||
|
#expect(targen.preconditioningProfile == "/tmp/pre.icm")
|
||||||
|
#expect(targen.ofpsHighQuality == true)
|
||||||
|
#expect(targen.ofpsAdaptation == 0.2)
|
||||||
|
#expect(targen.fullSpreadAlgorithm == .uniformRandom)
|
||||||
|
#expect(targen.totalInkLimit == 280)
|
||||||
|
#expect(targen.darkEmphasis == 1.3)
|
||||||
|
#expect(targen.devicePower == 1.2)
|
||||||
|
|
||||||
|
let printtarg = PrinttargConfig(
|
||||||
|
preset: preset,
|
||||||
|
basename: "j",
|
||||||
|
workingDirectory: nil,
|
||||||
|
calibrationFile: preset.calibrationFile
|
||||||
|
)
|
||||||
|
#expect(printtarg.instrument == .p3)
|
||||||
|
#expect(printtarg.pageSize == .custom)
|
||||||
|
#expect(printtarg.customPageWidth == 250)
|
||||||
|
#expect(printtarg.customPageHeight == 300)
|
||||||
|
#expect(printtarg.bitDepth == .sixteen)
|
||||||
|
#expect(printtarg.dpi == 360)
|
||||||
|
#expect(printtarg.layoutOrder == .customSeed)
|
||||||
|
#expect(printtarg.customSeed == 42)
|
||||||
|
#expect(printtarg.calibrationFile == "/tmp/a.cal")
|
||||||
|
|
||||||
|
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
|
||||||
|
#expect(colprof.algorithm == "x")
|
||||||
|
#expect(colprof.quality == "u")
|
||||||
|
#expect(colprof.intent == "p")
|
||||||
|
#expect(colprof.fwa == "D65")
|
||||||
|
#expect(colprof.illuminant == "D65")
|
||||||
|
#expect(colprof.observer == "1931_2")
|
||||||
|
#expect(colprof.inputViewingCond == "D50_2")
|
||||||
|
#expect(colprof.outputViewingCond == "D65_2")
|
||||||
|
|
||||||
|
let back = ProfilingPreset(
|
||||||
|
id: preset.id,
|
||||||
|
name: preset.name,
|
||||||
|
description: preset.description,
|
||||||
|
targen: targen,
|
||||||
|
printtarg: printtarg,
|
||||||
|
colprof: colprof,
|
||||||
|
calibrationFile: preset.calibrationFile,
|
||||||
|
applyCalibration: preset.applyCalibration
|
||||||
|
)
|
||||||
|
#expect(back == preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every full-spread algorithm round-trips", arguments: [
|
||||||
|
("ofps", FullSpreadAlgorithm.ofps),
|
||||||
|
("t", .target),
|
||||||
|
("r", .random),
|
||||||
|
("R", .uniformRandom),
|
||||||
|
("q", .quasiRandom),
|
||||||
|
("Q", .uniformQuasiRandom),
|
||||||
|
("i", .invertedQuasiRandom),
|
||||||
|
("I", .invertedUniformQuasiRandom)
|
||||||
|
])
|
||||||
|
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) {
|
||||||
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
|
preset.fullSpreadAlgorithm = value
|
||||||
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
|
if expected == .ofps {
|
||||||
|
// ofps is the default — no flag emitted, stored value is nil.
|
||||||
|
#expect(cfg.fullSpreadAlgorithm == nil)
|
||||||
|
} else {
|
||||||
|
#expect(cfg.fullSpreadAlgorithm == expected)
|
||||||
|
}
|
||||||
|
let back = ProfilingPreset(
|
||||||
|
id: "x", name: "n", description: "",
|
||||||
|
targen: cfg,
|
||||||
|
printtarg: PrinttargConfig(
|
||||||
|
preset: preset, basename: "t",
|
||||||
|
workingDirectory: nil, calibrationFile: nil
|
||||||
|
),
|
||||||
|
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
|
||||||
|
calibrationFile: nil,
|
||||||
|
applyCalibration: nil
|
||||||
|
)
|
||||||
|
#expect(back.fullSpreadAlgorithm == value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil")
|
||||||
|
func ofpsHighQualityFalse() {
|
||||||
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
|
preset.ofpsHighQuality = false
|
||||||
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
|
#expect(cfg.ofpsHighQuality == false)
|
||||||
|
|
||||||
|
preset.ofpsHighQuality = nil
|
||||||
|
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
|
#expect(nilCfg.ofpsHighQuality == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("noRandomize/seed layout mapping rules", arguments: [
|
||||||
|
(true, nil, LayoutOrder.raster, 1),
|
||||||
|
(true, 7, .raster, 7),
|
||||||
|
(false, nil, .deterministic, 1),
|
||||||
|
(false, 1, .deterministic, 1),
|
||||||
|
(nil, 1, .deterministic, 1),
|
||||||
|
(false, 5, .customSeed, 5)
|
||||||
|
] as [(Bool?, Int?, LayoutOrder, Int)])
|
||||||
|
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) {
|
||||||
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
|
preset.noRandomize = noRandomize
|
||||||
|
preset.randomSeed = seed
|
||||||
|
let cfg = PrinttargConfig(
|
||||||
|
preset: preset, basename: "t",
|
||||||
|
workingDirectory: nil, calibrationFile: nil
|
||||||
|
)
|
||||||
|
#expect(cfg.layoutOrder == layout)
|
||||||
|
#expect(cfg.customSeed == expectedSeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Custom page fallback matrix", arguments: [
|
||||||
|
("250x300", PageSize.custom, 250.0, 300.0),
|
||||||
|
("50x50", .custom, 50.0, 50.0),
|
||||||
|
("foo", .a4, 210.0, 297.0),
|
||||||
|
("30x40", .a4, 210.0, 297.0),
|
||||||
|
("210x", .a4, 210.0, 297.0)
|
||||||
|
] as [(String, PageSize, Double, Double)])
|
||||||
|
func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) {
|
||||||
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
|
preset.pageSize = raw
|
||||||
|
let cfg = PrinttargConfig(
|
||||||
|
preset: preset, basename: "t",
|
||||||
|
workingDirectory: nil, calibrationFile: nil
|
||||||
|
)
|
||||||
|
#expect(cfg.pageSize == page)
|
||||||
|
#expect(cfg.customPageWidth == w)
|
||||||
|
#expect(cfg.customPageHeight == h)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FWA preset value → selection matrix", arguments: [
|
||||||
|
(nil, ColprofFwaSelection.none),
|
||||||
|
("none", .none),
|
||||||
|
("NONE", .none),
|
||||||
|
("", .empty),
|
||||||
|
("D50", .D50),
|
||||||
|
("d50", .D50),
|
||||||
|
("D65", .D65),
|
||||||
|
("d65", .D65),
|
||||||
|
("/tmp/fwa.sp", .custom)
|
||||||
|
] as [(String?, ColprofFwaSelection)])
|
||||||
|
func fwaToSelection(raw: String?, expected: ColprofFwaSelection) {
|
||||||
|
#expect(ColprofFwaSelection(presetValue: raw) == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FWA selection → preset value matrix", arguments: [
|
||||||
|
(ColprofFwaSelection.none, nil),
|
||||||
|
(.empty, ""),
|
||||||
|
(.D50, "D50"),
|
||||||
|
(.D65, "D65"),
|
||||||
|
(.custom, "/tmp/fwa.sp")
|
||||||
|
] as [(ColprofFwaSelection, String?)])
|
||||||
|
func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) {
|
||||||
|
#expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #82 — preset application through the live view models, under an
|
||||||
|
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
|
||||||
|
@Suite("PresetViewModelMapping")
|
||||||
|
@MainActor
|
||||||
|
struct PresetViewModelMappingTests {
|
||||||
|
|
||||||
|
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
|
||||||
|
let env = try TestAppEnvironment.make()
|
||||||
|
return (env, TargetWorkflowViewModel(environment: env.environment))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path")
|
||||||
|
func nilFwaClearsCustomPath() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
var customPreset = ProfilingPreset(
|
||||||
|
id: "c-fwa", name: "FWA", patchCount: 800,
|
||||||
|
colprofFwa: "/tmp/fwa.sp"
|
||||||
|
)
|
||||||
|
vm.applyPreset(customPreset)
|
||||||
|
#expect(vm.profile.fwaSelection == .custom)
|
||||||
|
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp")
|
||||||
|
|
||||||
|
customPreset.colprofFwa = nil
|
||||||
|
vm.applyPreset(customPreset)
|
||||||
|
#expect(vm.profile.fwaSelection == .none)
|
||||||
|
#expect(vm.profile.fwaCustomPath == "")
|
||||||
|
#expect(vm.profile.fwaValue == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Custom FWA preset path survives the round-trip to colprof_fwa")
|
||||||
|
func customFwaRoundTrip() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "c-fwa2", name: "FWA2", patchCount: 800,
|
||||||
|
colprofFwa: "/tmp/other.sp"
|
||||||
|
)
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
#expect(vm.profile.fwaSelection == .custom)
|
||||||
|
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp")
|
||||||
|
#expect(vm.profile.fwaValue == "/tmp/other.sp")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Preset calibration reaches Stage 2 instead of stale live state")
|
||||||
|
func presetCalibrationReachesStage2() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
// Stale live state must not leak into the preset-applied layout.
|
||||||
|
vm.profile.applyCalibration = true
|
||||||
|
vm.profile.calibrationFile = "/tmp/stale.cal"
|
||||||
|
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "c-cal", name: "Cal", patchCount: 800,
|
||||||
|
calibrationFile: "/tmp/preset.cal",
|
||||||
|
applyCalibration: true
|
||||||
|
)
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
|
#expect(vm.profile.applyCalibration)
|
||||||
|
#expect(vm.profile.calibrationFile == "/tmp/preset.cal")
|
||||||
|
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Preset with calibration disabled clears Stage 2 calibration")
|
||||||
|
func disabledCalibrationClearsStage2() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
vm.profile.applyCalibration = true
|
||||||
|
vm.profile.calibrationFile = "/tmp/stale.cal"
|
||||||
|
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "c-nocal", name: "NoCal", patchCount: 800,
|
||||||
|
calibrationFile: "/tmp/preset.cal",
|
||||||
|
applyCalibration: nil
|
||||||
|
)
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
|
#expect(!vm.profile.applyCalibration)
|
||||||
|
#expect(vm.buildPrinttargConfig().calibrationFile == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Preset Stage 1/2 form fields apply to the live form")
|
||||||
|
func formFieldsApply() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
var preset = ProfilingPreset(
|
||||||
|
id: "c-form", name: "Form",
|
||||||
|
colourSpace: "cmyk", patchCount: 1500,
|
||||||
|
whitePatches: 6,
|
||||||
|
blackPatches: 8,
|
||||||
|
greySteps: 9,
|
||||||
|
fullSpreadAlgorithm: "r",
|
||||||
|
pageSize: "250x300",
|
||||||
|
dpi: 150
|
||||||
|
)
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
|
#expect(vm.colourSpace == .cmyk)
|
||||||
|
#expect(vm.effectivePatchCount == 1500)
|
||||||
|
#expect(vm.whitePatches == 6)
|
||||||
|
#expect(vm.blackPatches == 8)
|
||||||
|
#expect(vm.greyStepsEnabled && vm.greySteps == 9)
|
||||||
|
#expect(vm.algorithm == .random)
|
||||||
|
#expect(vm.tiffDpi == 150)
|
||||||
|
#expect(vm.pageSize == .custom)
|
||||||
|
#expect(vm.customPageW == 250 && vm.customPageH == 300)
|
||||||
|
#expect(vm.selectedPresetID == "c-form")
|
||||||
|
|
||||||
|
// Disabled advanced controls stay nil in the snapshot, not
|
||||||
|
// numeric sentinels.
|
||||||
|
preset.greySteps = nil
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
#expect(!vm.greyStepsEnabled)
|
||||||
|
#expect(vm.buildTargenConfig().greySteps == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,59 @@ struct ProcessManagerTests {
|
|||||||
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribes synchronously (registration happens inside `events()`)
|
||||||
|
/// then records every event for `id` until the task is cancelled.
|
||||||
|
/// Unlike `collect`, observation continues past `.exit` so tests can
|
||||||
|
/// prove exactly-once exit emission.
|
||||||
|
private func observe(
|
||||||
|
_ manager: ProcessManager,
|
||||||
|
id: String,
|
||||||
|
into box: Box
|
||||||
|
) -> Task<Void, Never> {
|
||||||
|
let stream = manager.events()
|
||||||
|
return Task {
|
||||||
|
for await event in stream {
|
||||||
|
guard event.id == id else { continue }
|
||||||
|
box.append(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exitCount(in box: Box) -> Int {
|
||||||
|
box.events.filter { if case .exit = $0 { return true }; return false }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForExit(in box: Box, timeout: TimeInterval = 10) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if exitCount(in: box) > 0 { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForFile(_ url: URL, timeout: TimeInterval = 5) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForRunning(
|
||||||
|
_ manager: ProcessManager,
|
||||||
|
id: String,
|
||||||
|
timeout: TimeInterval = 5
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if await manager.isRunning(id) { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
@Test func streamsStdoutAndEmitsExit() async throws {
|
@Test func streamsStdoutAndEmitsExit() async throws {
|
||||||
@@ -214,6 +267,145 @@ struct ProcessManagerTests {
|
|||||||
try await pm.sendStdin(id: "nope", text: "d\n")
|
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let marker = Self.fixtureDir
|
||||||
|
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
||||||
|
let bin = try script(
|
||||||
|
"partial-row.sh",
|
||||||
|
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":9}'\ntouch \"$1\"\nsleep 30\n"
|
||||||
|
)
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t11", into: box)
|
||||||
|
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
|
||||||
|
#expect(await waitForFile(marker))
|
||||||
|
// Retry the flush so the pipe-ingest task can win the actor race
|
||||||
|
// on a loaded host; the first successful flush emits the row.
|
||||||
|
var flushed = false
|
||||||
|
for _ in 0..<50 {
|
||||||
|
await pm.flushPartialLine(id: "t11")
|
||||||
|
if box.events.contains(where: { if case .jsonRow = $0 { return true }; return false }) {
|
||||||
|
flushed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
}
|
||||||
|
#expect(flushed)
|
||||||
|
await pm.kill(id: "t11")
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
observer.cancel()
|
||||||
|
let events = box.events
|
||||||
|
let rows = events.compactMap { e -> String? in
|
||||||
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#expect(rows == ["{\"row\":9}"])
|
||||||
|
// Prefixed tails must not leak into stdout, even via finalize.
|
||||||
|
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
|
||||||
|
#expect(exitCount(in: box) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script(
|
||||||
|
"row-tail.sh",
|
||||||
|
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":42}'\n"
|
||||||
|
)
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t12", into: box)
|
||||||
|
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
observer.cancel()
|
||||||
|
let events = box.events
|
||||||
|
let rows = events.compactMap { e -> String? in
|
||||||
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#expect(rows == ["{\"row\":42}"])
|
||||||
|
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
|
||||||
|
let rowIndex = events.firstIndex {
|
||||||
|
if case .jsonRow = $0 { return true }; return false
|
||||||
|
}
|
||||||
|
let exitIndexes = events.indices.filter {
|
||||||
|
if case .exit = events[$0] { return true }; return false
|
||||||
|
}
|
||||||
|
#expect(exitIndexes.count == 1)
|
||||||
|
if let rowIndex, let exitIndex = exitIndexes.first {
|
||||||
|
#expect(rowIndex < exitIndex)
|
||||||
|
} else {
|
||||||
|
Issue.record("expected a jsonRow before the exit event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fastStreamingExitEmitsExactlyOneExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t13", into: box)
|
||||||
|
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
// The grace window must outlast the 2 s finalize watchdog so a
|
||||||
|
// duplicate emission from it would be observed.
|
||||||
|
try await Task.sleep(for: .milliseconds(2500))
|
||||||
|
observer.cancel()
|
||||||
|
#expect(box.events == [.exit(id: "t13", code: 0)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fastCapturedExitEmitsExactlyOneExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t14", into: box)
|
||||||
|
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
|
||||||
|
#expect(result.exitCode == 7)
|
||||||
|
// Both the termination handler and the waitUntilExit watchdog
|
||||||
|
// resume the same box; give the slower path time to fire.
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
observer.cancel()
|
||||||
|
#expect(box.events == [.exit(id: "t14", code: 7)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func capturedRunSetsArgyllNotInteractive() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script(
|
||||||
|
"cap-env.sh",
|
||||||
|
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
|
||||||
|
)
|
||||||
|
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
|
||||||
|
#expect(result.stdout == "ANI=1\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let marker = Self.fixtureDir
|
||||||
|
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
||||||
|
let slowBin = try script("mixed-slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
|
let capBin = try script("mixed-cap.sh", "#!/bin/sh\ntouch \"$1\"\nsleep 30\n")
|
||||||
|
let streamBox = Box()
|
||||||
|
let capBox = Box()
|
||||||
|
let streamObserver = observe(pm, id: "t16", into: streamBox)
|
||||||
|
let capObserver = observe(pm, id: "t17", into: capBox)
|
||||||
|
try await pm.runStreaming(id: "t16", binary: slowBin, arguments: [])
|
||||||
|
let capTask = Task {
|
||||||
|
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
|
||||||
|
}
|
||||||
|
#expect(await waitForFile(marker))
|
||||||
|
#expect(await waitForRunning(pm, id: "t16"))
|
||||||
|
#expect(await waitForRunning(pm, id: "t17"))
|
||||||
|
#expect(await pm.killAll() == 2)
|
||||||
|
_ = try await capTask.value
|
||||||
|
#expect(await waitForExit(in: streamBox))
|
||||||
|
#expect(await waitForExit(in: capBox))
|
||||||
|
// Grace window outlasts the streaming finalize watchdog.
|
||||||
|
try await Task.sleep(for: .milliseconds(2500))
|
||||||
|
streamObserver.cancel()
|
||||||
|
capObserver.cancel()
|
||||||
|
#expect(!(await pm.isRunning("t16")))
|
||||||
|
#expect(!(await pm.isRunning("t17")))
|
||||||
|
#expect(exitCount(in: streamBox) == 1)
|
||||||
|
#expect(exitCount(in: capBox) == 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ProcessLineDecoder")
|
@Suite("ProcessLineDecoder")
|
||||||
|
|||||||
@@ -93,6 +93,28 @@ struct SettingsStoreTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
|
||||||
|
let url = tempStoreURL()
|
||||||
|
let store = SettingsStore(fileURL: url)
|
||||||
|
var valid = AppSettings.default
|
||||||
|
valid.deltaEGoodMax = 1.5
|
||||||
|
try store.save(valid)
|
||||||
|
let originalBytes = try Data(contentsOf: url)
|
||||||
|
|
||||||
|
var fired = false
|
||||||
|
let token = NotificationCenter.default.addObserver(
|
||||||
|
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
|
||||||
|
) { _ in fired = true }
|
||||||
|
defer { NotificationCenter.default.removeObserver(token) }
|
||||||
|
|
||||||
|
var invalid = AppSettings.default
|
||||||
|
invalid.deltaEGoodMax = 9.0
|
||||||
|
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
|
||||||
|
#expect(try Data(contentsOf: url) == originalBytes)
|
||||||
|
#expect(!fired)
|
||||||
|
#expect(store.load() == valid)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func savePostsNotification() async throws {
|
@Test func savePostsNotification() async throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Shared app-test dependency factory (issue #82).
|
||||||
|
///
|
||||||
|
/// Every store is pointed at a unique temporary directory so tests never
|
||||||
|
/// read or write the user's real Application Support tree, and a fresh
|
||||||
|
/// `ProcessManager` keeps child-process state isolated per test. The
|
||||||
|
/// global process environment is never mutated.
|
||||||
|
struct TestAppEnvironment {
|
||||||
|
|
||||||
|
/// Root temp directory holding all per-test state files.
|
||||||
|
let root: URL
|
||||||
|
let environment: AppEnvironment
|
||||||
|
|
||||||
|
var settingsURL: URL { root.appendingPathComponent("settings.json") }
|
||||||
|
var stateURL: URL { root.appendingPathComponent("wizard_state.json") }
|
||||||
|
var historyURL: URL {
|
||||||
|
root.appendingPathComponent("verification_history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||||
|
/// Call `cleanup()` when finished.
|
||||||
|
static func make() throws -> TestAppEnvironment {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root, withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let processManager = ProcessManager()
|
||||||
|
let settingsStore = SettingsStore(
|
||||||
|
fileURL: root.appendingPathComponent("settings.json")
|
||||||
|
)
|
||||||
|
let environment = AppEnvironment(
|
||||||
|
stateStore: WizardStateStore(
|
||||||
|
fileURL: root.appendingPathComponent("wizard_state.json")
|
||||||
|
),
|
||||||
|
settingsStore: settingsStore,
|
||||||
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
|
runner: ArgyllRunner(
|
||||||
|
processManager: processManager,
|
||||||
|
binaryResolver: BinaryResolver(overrideDir: nil)
|
||||||
|
),
|
||||||
|
cupsService: CupsService(
|
||||||
|
processManager: processManager,
|
||||||
|
binaryDir: root.appendingPathComponent("cups-bin")
|
||||||
|
),
|
||||||
|
historyStore: VerificationHistoryStore(
|
||||||
|
url: root.appendingPathComponent("verification_history.json")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return TestAppEnvironment(root: root, environment: environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the temporary root directory.
|
||||||
|
func cleanup() {
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,57 @@ struct VerificationHistoryStoreTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Clear does not overwrite an unparseable file")
|
||||||
|
func clearPreservesUnparseableFile() async {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
|
let url = tmp.appendingPathComponent("verification_history.json")
|
||||||
|
|
||||||
|
let badJSON = "not json"
|
||||||
|
try? badJSON.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let store = VerificationHistoryStore(url: url)
|
||||||
|
do {
|
||||||
|
try await store.clear()
|
||||||
|
Issue.record("clear() should propagate the load error")
|
||||||
|
} catch {
|
||||||
|
let contents = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(contents == badJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ISO-8601 timestamps round-trip through a fresh store")
|
||||||
|
func iso8601RoundTrip() async throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
|
let url = tmp.appendingPathComponent("verification_history.json")
|
||||||
|
|
||||||
|
let timestamp = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let record = VerificationRecord(
|
||||||
|
id: "vr-iso",
|
||||||
|
profileName: "p",
|
||||||
|
printerName: "",
|
||||||
|
avgDE: 1.0,
|
||||||
|
maxDE: 2.0,
|
||||||
|
rmsDE: 1.5,
|
||||||
|
patchCount: 1,
|
||||||
|
status: .good,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
let store1 = VerificationHistoryStore(url: url)
|
||||||
|
_ = try await store1.append(record)
|
||||||
|
|
||||||
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(text.contains(ISO8601DateFormatter().string(from: timestamp)))
|
||||||
|
|
||||||
|
let store2 = VerificationHistoryStore(url: url)
|
||||||
|
let loaded = try await store2.load()
|
||||||
|
#expect(loaded.count == 1)
|
||||||
|
#expect(loaded.first?.timestamp == timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("CSV export quoting")
|
@Test("CSV export quoting")
|
||||||
func csvQuoting() async throws {
|
func csvQuoting() async throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ struct WizardStateStoreTests {
|
|||||||
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func corruptJsonReturnsDefaultAndKeepsBytes() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
#expect(WizardStateStore(fileURL: url).load() == .default)
|
||||||
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(kept == "not json")
|
||||||
|
}
|
||||||
|
|
||||||
@Test func sessionModeCalibrationRoundTrips() throws {
|
@Test func sessionModeCalibrationRoundTrips() throws {
|
||||||
var s = WizardState(sessionMode: .calibration)
|
var s = WizardState(sessionMode: .calibration)
|
||||||
let data = try JSONEncoder().encode(s)
|
let data = try JSONEncoder().encode(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user