Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c539507d5d | ||
|
|
0ffcf5ea91 | ||
|
|
597cce7b60 | ||
|
|
bb4512e129 |
@@ -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)? {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user