Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c539507d5d fix(profile): complete calibration identity and profile resolution (#83)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 11:10:10 +01:00
gronod 0ffcf5ea91 Merge pull request 'fix(presets): complete config mapping contracts (#82)' (#98) from feat/82-preset-mapping-contracts into milestone/m8-consolidation 2026-09-11 10:58:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 597cce7b60 fix(presets): complete config mapping contracts (#82)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 10:53:41 +01:00
gronod bb4512e129 Merge pull request 'fix(persistence): complete M8 JSON store contracts (#81)' (#97) from feat/81-json-store-contracts into milestone/m8-consolidation 2026-09-11 10:38:53 +01:00
12 changed files with 655 additions and 92 deletions
@@ -79,14 +79,17 @@ public enum ArtefactProbe {
}
/// 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(
_ url: URL,
fileManager: FileManager = .default
) -> URL {
if fileManager.fileExists(atPath: url.path) { return url }
let altExt = url.pathExtension.lowercased() == "icc" ? "icm" : "icc"
let alt = url.deletingPathExtension().appendingPathExtension(altExt)
let ext = url.pathExtension.lowercased()
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
}
@@ -33,16 +33,16 @@ public struct CalibrationIdentity: Equatable, Sendable {
/// Derive identity from the live wizard basename and the persisted
/// 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 {
if liveBasename.isEmpty && persistedOriginal.isEmpty {
guard !liveBasename.isEmpty else {
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
}
let original: String
if liveBasename.hasPrefix("CAL_") {
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
} else if liveBasename.isEmpty {
original = persistedOriginal
} else {
original = liveBasename
}
@@ -14,7 +14,9 @@ extension TargenConfig {
neutralSteps: preset.neutralSteps,
neutralConcentration: preset.neutralConcentration,
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,
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
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 {
/// `"210x297"` custom page parse used by presets (issue #82).
public static func parseCustom(_ raw: String) -> (Double, Double)? {
+7 -37
View File
@@ -3,25 +3,6 @@ import Observation
import SwiftUI
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.
@MainActor
@Observable
@@ -110,13 +91,7 @@ final class ProfileWorkflowViewModel {
}
var fwaValue: String? {
switch fwaSelection {
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return fwaCustomPath
}
fwaSelection.presetValue(customPath: fwaCustomPath)
}
// MARK: - Preset application
@@ -131,21 +106,16 @@ final class ProfileWorkflowViewModel {
algorithm = config.algorithm
quality = config.quality
intent = config.intent ?? ""
if let fwa = config.fwa {
switch fwa.lowercased() {
case "none": fwaSelection = .none
case "": fwaSelection = .empty
case "d50": fwaSelection = .D50
case "d65": fwaSelection = .D65
default:
fwaSelection = .custom
fwaCustomPath = fwa
}
}
fwaSelection = ColprofFwaSelection(presetValue: config.fwa)
fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : ""
illuminant = config.illuminant ?? ""
observer = config.observer ?? ""
inputViewingCond = config.inputViewingCond ?? ""
outputViewingCond = config.outputViewingCond ?? ""
profileDescription = ""
copyright = ""
applyCalibration = preset.applyCalibration == true
calibrationFile = preset.calibrationFile ?? ""
}
/// Stage 4 form values for saving into a custom preset.
+6 -2
View File
@@ -380,14 +380,18 @@ final class TargetWorkflowViewModel {
func applyPreset(_ preset: ProfilingPreset) {
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
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(
preset: preset,
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory,
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil
calibrationFile: profile.applyCalibration && !profile.calibrationFile.isEmpty
? profile.calibrationFile : nil
)
applyPrinttargForm(printtarg)
profile.applyPreset(preset)
selectedPresetID = preset.id
}
@@ -41,6 +41,34 @@ struct ArgyllRunnerCalibrationTests {
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")
func printcalProducesCal() async throws {
let testRoot = try makeTestDir()
+83 -7
View File
@@ -125,26 +125,102 @@ struct ArtefactFilesTests {
@Suite("ArtefactProbe profile resolve")
struct ArtefactProbeProfileTests {
@Test("basename probe prefers .icm")
func icmWins() throws {
private func makeDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)")
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("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)
#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")
func flipExtension() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let resolved = ArtefactProbe.resolveProfile(icc)
#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")
}
}
@@ -82,41 +82,3 @@ struct JSONFileStoreTests {
}
}
}
@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 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")
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)
}
}
+206
View File
@@ -287,4 +287,210 @@ struct PresetMappingTests {
#expect(back.colprofFwa == "D50")
#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)
}
}
@@ -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)
}
}