diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift index f7f36dc..f963ea6 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift @@ -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)? { diff --git a/Sources/ICCery/ProfileWorkflowViewModel.swift b/Sources/ICCery/ProfileWorkflowViewModel.swift index 589ed21..f0f419a 100644 --- a/Sources/ICCery/ProfileWorkflowViewModel.swift +++ b/Sources/ICCery/ProfileWorkflowViewModel.swift @@ -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. diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 52159d2..67f4ab8 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -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 } diff --git a/Tests/ICCeryCoreTests/PresetTests.swift b/Tests/ICCeryCoreTests/PresetTests.swift index eaf482b..7a5167a 100644 --- a/Tests/ICCeryCoreTests/PresetTests.swift +++ b/Tests/ICCeryCoreTests/PresetTests.swift @@ -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) + } } diff --git a/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift b/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift new file mode 100644 index 0000000..30a96e4 --- /dev/null +++ b/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift @@ -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) + } +} diff --git a/Tests/ICCeryCoreTests/TestAppEnvironment.swift b/Tests/ICCeryCoreTests/TestAppEnvironment.swift new file mode 100644 index 0000000..ac27fe4 --- /dev/null +++ b/Tests/ICCeryCoreTests/TestAppEnvironment.swift @@ -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) + } +}