Compare commits

..
Author SHA1 Message Date
gronod fa9a2042bf chore: simplify megaplans gitignore to directory-level pattern
Remove redundant `docs/megaplans/*` pattern, keeping only `docs/megaplans` to ignore the entire directory.
2026-09-11 12:01:08 +01:00
79 changed files with 2007 additions and 2550 deletions
+2 -16
View File
@@ -14,22 +14,13 @@ jobs:
build-and-test: build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not, # Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline. # macos-14 works for this pipeline.
runs-on: macos-12 runs-on: macos-14
env: env:
DERIVED: build/DerivedData-test DERIVED: build/DerivedData-test
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Assert Xcode 14 toolchain
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead.
- name: Ensure host tools
run: scripts/ensure-host-tools.sh
- name: Generate Xcode project - name: Generate Xcode project
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
@@ -129,17 +120,12 @@ jobs:
package: package:
needs: build-and-test needs: build-and-test
runs-on: macos-12 runs-on: macos-14
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
- name: Ensure host tools
run: scripts/ensure-host-tools.sh
- name: Package release - name: Package release
run: scripts/package-release.sh run: scripts/package-release.sh
env: env:
-1
View File
@@ -27,5 +27,4 @@ ICCery.xcodeproj/
Release/ Release/
notarization/ notarization/
build/ build/
docs/megaplans/*
docs/megaplans docs/megaplans
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 5.7 // swift-tools-version: 6.0
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "ICCeryCore", name: "ICCeryCore",
platforms: [.macOS(.v12)], platforms: [.macOS(.v14)],
products: [ products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]), .library(name: "ICCeryCore", targets: ["ICCeryCore"]),
], ],
@@ -200,7 +200,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id) await processManager.kill(id: id)
var attempts = 0 var attempts = 0
while await processManager.isRunning(id), attempts < 30 { while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(nanoseconds: 100_000_000) try? await Task.sleep(for: .milliseconds(100))
attempts += 1 attempts += 1
} }
} }
@@ -243,7 +243,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines { if flushPartialLines {
dotFlushTask = Task { [processManager] in dotFlushTask = Task { [processManager] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { break } if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId) await processManager.flushPartialLine(id: processId)
} }
@@ -592,7 +592,7 @@ public struct ArgyllRunner: Sendable {
await processManager.setPreKillHook(id: processId) { [processManager] in await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY { if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(for: .milliseconds(500))
} }
} }
@@ -56,19 +56,23 @@ public enum PrinttargArgs {
} }
args.append(contentsOf: ["-R", "\(config.customSeed)"]) args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster: case .raster:
args.append(contentsOf: ArgsBuilder.flag("-r", when: true)) args.append("-r")
} }
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label)) if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
guard (72...600).contains(config.dpi) else { guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi) throw PrinttargArgError.invalidDPI(config.dpi)
} }
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"]) args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !CalibrationIdentity.isCalibration(cleanBasename) { if !CalibrationIdentity.isCalibration(cleanBasename),
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty( let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile)) !cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
} }
args.append(cleanBasename) args.append(cleanBasename)
@@ -70,12 +70,18 @@ public enum TargenArgs {
if let n = config.neutralSteps, n > 0 { if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"]) args.append(contentsOf: ["-n", "\(n)"])
} }
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50)) if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile)) args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true)) }
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map { if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0) args.append(contentsOf: ["-c", c])
})) }
if config.ofpsHighQuality == true {
args.append("-G")
}
if let a = config.ofpsAdaptation {
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
}
if let algFlag = config.fullSpreadAlgorithm?.flag { if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag) args.append(algFlag)
} }
@@ -85,9 +91,11 @@ public enum TargenArgs {
} }
args.append(contentsOf: ["-l", "\(inkLimit)"]) args.append(contentsOf: ["-l", "\(inkLimit)"])
} }
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0)) if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
if let p = config.devicePower, p > 0 { args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0)) }
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
} }
args.append(cleanBasename) args.append(cleanBasename)
@@ -72,8 +72,8 @@ public enum CGATSParser {
public static func parse( public static func parse(
_ contents: String, _ contents: String,
sourceURL: URL? = nil sourceURL: URL? = nil
) throws -> CGATSDataset { ) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw CGATSParseError.emptyFile } guard !contents.isEmpty else { throw .emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? "" let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines) let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -101,10 +101,10 @@ public enum CGATSParser {
} }
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else { guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw CGATSParseError.missingBeginDataFormat throw .missingBeginDataFormat
} }
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else { guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw CGATSParseError.missingBeginData throw .missingBeginData
} }
let rawFieldNames = splitFields(lines[formatStart + 1]) let rawFieldNames = splitFields(lines[formatStart + 1])
@@ -139,7 +139,7 @@ public enum CGATSParser {
let lineIndex = dataStart + offset let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex]) let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else { guard rawRow.count == fieldNames.count else {
throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count) throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
} }
var sample = RawSample(id: String(offset), lineIndex: lineIndex) var sample = RawSample(id: String(offset), lineIndex: lineIndex)
@@ -153,7 +153,7 @@ public enum CGATSParser {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number) groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
} }
} else if !cleaned.isEmpty { } else if !cleaned.isEmpty {
throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1) throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
} }
} else { } else {
sample.strings[name] = raw sample.strings[name] = raw
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess( private static func preprocess(
_ contents: String, _ contents: String,
isCSV: Bool isCSV: Bool
) throws -> (CGATSFormat, [String]) { ) throws(CGATSParseError) -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines) let allLines = contents.components(separatedBy: .newlines)
var lines = [String]() var lines = [String]()
@@ -239,7 +239,7 @@ public enum CGATSParser {
lines.append(line) lines.append(line)
} }
guard !lines.isEmpty else { throw CGATSParseError.emptyFile } guard !lines.isEmpty else { throw .emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block // Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it. // structure so the boundary-based parser below can handle it.
@@ -559,7 +559,7 @@ public actor ProcessManager {
// Start a watchdog in case the `readabilityHandler` EOFs never // Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe). // arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in child.finalizeTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(for: .seconds(2))
guard let self else { return } guard let self else { return }
await self.forceKill(id: id) await self.forceKill(id: id)
await self.forceFinalize(id: id) await self.forceFinalize(id: id)
@@ -79,9 +79,16 @@ public enum PrintcalArgs {
var args: [String] = ["-v", "-e"] var args: [String] = ["-v", "-e"]
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit)) if config.noInkLimit {
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify)) args.append("-I")
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath)) }
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
if let tac = config.totalInkLimit, tac > 0 { if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)]) args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit { } else if let tac = config.totalInkLimit {
+11 -3
View File
@@ -3,8 +3,8 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07). /// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View { struct CalibrationView: View {
@ObservedObject var model: CalibrationViewModel @Bindable var model: CalibrationViewModel
@ObservedObject var wizard: WizardViewModel @Bindable var wizard: WizardViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -73,7 +73,7 @@ struct CalibrationView: View {
if let url = model.computedCalURL { if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() } .onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle") .accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)") Text("Loaded: \(url.lastPathComponent)")
@@ -95,7 +95,15 @@ struct CalibrationView: View {
.frame(minHeight: 80, maxHeight: 120) .frame(minHeight: 80, maxHeight: 120)
} }
} }
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
} }
}
}
.formStyle(.grouped)
HStack { HStack {
Spacer() Spacer()
+36 -32
View File
@@ -1,12 +1,13 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and /// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves. /// compute `.cal` curves.
@MainActor @MainActor
final class CalibrationViewModel: ObservableObject { @Observable
final class CalibrationViewModel {
let workflow: TargetWorkflowViewModel let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel let profile: ProfileWorkflowViewModel
@@ -14,16 +15,17 @@ final class CalibrationViewModel: ObservableObject {
// MARK: - Form state // MARK: - Form state
@Published var colourSpace: ColourSpace = .cmyk var colourSpace: ColourSpace = .cmyk
@Published var steps: Int = 21 var steps: Int = 21
@Published var whitePatches: Int = 4 var whitePatches: Int = 4
@Published var includeNeutralEmphasis: Bool = false var includeNeutralEmphasis: Bool = false
@Published var inkLimit: String = "320" var inkLimit: String = "320"
@Published var applyToProfile: Bool = false var applyToProfile: Bool = false
@Published var computedCalURL: URL? var computedCalURL: URL?
@Published var calibrationLog: [String] = [] var calibrationLog: [String] = []
@Published var isGenerating = false var isGenerating = false
@Published var isComputing = false var isComputing = false
var lastError: String?
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow self.workflow = workflow
@@ -75,6 +77,10 @@ final class CalibrationViewModel: ObservableObject {
wizard.basename = identity.calibrationBasename wizard.basename = identity.calibrationBasename
wizard.sessionMode = .calibration wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: colourSpace, colourSpace: colourSpace,
steps: steps, steps: steps,
@@ -86,19 +92,17 @@ final class CalibrationViewModel: ObservableObject {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isGenerating = false }
do { do {
_ = try await ProcessRunSupport.runLogged( _ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
setRunning: { self.isGenerating = $0 }, self?.calibrationLog.append(contentsOf: batch)
resetLog: { self.calibrationLog = [] }, })
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runCalibrationTargen(
config: config, onLogBatch: onLog)
}
self.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.") self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint) self.wizard.go(to: .layOutPrint)
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)", "Calibration target failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -133,13 +137,15 @@ final class CalibrationViewModel: ObservableObject {
// "already exists" when the user declines overwrite. We do not // "already exists" when the user declines overwrite. We do not
// silently clobber. // silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) { if FileManager.default.fileExists(atPath: outputURL.path) {
wizard.showNotice( lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.", wizard.showNotice(lastError!, kind: .error)
kind: .error
)
return return
} }
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: calBasename, ti3Basename: calBasename,
workingDirectory: cwd, workingDirectory: cwd,
@@ -152,21 +158,19 @@ final class CalibrationViewModel: ObservableObject {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isComputing = false }
do { do {
let url = try await ProcessRunSupport.runLogged( let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
setRunning: { self.isComputing = $0 }, self?.calibrationLog.append(contentsOf: batch)
resetLog: { self.calibrationLog = [] }, })
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runPrintcal(
config: config, onLogBatch: onLog)
}
self.computedCalURL = url self.computedCalURL = url
self.profile.calibrationFile = url.path self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.") self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration() self.wizard.restoreCalibration()
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)", "Calibration curve computation failed: \(error.localizedDescription)",
kind: .error kind: .error
+7 -30
View File
@@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder {
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* /// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
/// (blue-yellow) is depth. /// (blue-yellow) is depth.
struct GamutView: View { struct GamutView: View {
@StateObject private var viewModel: GamutViewModel @State private var viewModel: GamutViewModel
@State private var pause: () -> Void = {} @State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool @FocusState private var isFocused: Bool
init(profileGamURL: URL? = nil) { init(profileGamURL: URL? = nil) {
_viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) _viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
} }
var body: some View { var body: some View {
@@ -87,6 +87,11 @@ struct GamutView: View {
) )
.focusable() .focusable()
.focused($isFocused) .focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.onAppear { isFocused = true } .onAppear { isFocused = true }
VStack { VStack {
@@ -143,7 +148,6 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView context.coordinator.scnView = scnView
context.coordinator.scene = scene context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.installKeyMonitor()
return scnView return scnView
} }
@@ -164,7 +168,6 @@ private struct GamutSceneView: NSViewRepresentable {
} }
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor()
nsView.isPlaying = false nsView.isPlaying = false
} }
@@ -172,7 +175,6 @@ private struct GamutSceneView: NSViewRepresentable {
final class Coordinator: NSObject { final class Coordinator: NSObject {
weak var scnView: SCNView? weak var scnView: SCNView?
weak var scene: SCNScene? weak var scene: SCNScene?
private var keyMonitor: Any?
private let profileNode = SCNNode() private let profileNode = SCNNode()
private let referenceGroup = SCNNode() private let referenceGroup = SCNNode()
@@ -429,31 +431,6 @@ private struct GamutSceneView: NSViewRepresentable {
scnView?.isPlaying = false scnView?.isPlaying = false
} }
/// Local key-down monitor for the R camera-reset shortcut (the
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
/// events aimed at this view's window are handled; everything
/// else passes through untouched.
func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
[weak self] event in
guard let self,
let scnView = self.scnView,
event.window === scnView.window,
event.charactersIgnoringModifiers?.uppercased() == "R"
else { return event }
self.resetCamera()
return nil
}
}
func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
}
func resetCamera() { func resetCamera() {
guard let scnView else { return } guard let scnView else { return }
+7 -6
View File
@@ -1,25 +1,26 @@
import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
import Observation
/// View model for the native SceneKit gamut viewer. /// View model for the native SceneKit gamut viewer.
/// ///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a /// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory. /// printer/profile `.gam` from the current working directory.
@MainActor @MainActor
final class GamutViewModel: ObservableObject { @Observable
final class GamutViewModel {
/// Parsed reference sRGB gamut mesh. /// Parsed reference sRGB gamut mesh.
@Published var sRGBMesh: GamutMesh? var sRGBMesh: GamutMesh?
/// Parsed printer/profile gamut mesh. /// Parsed printer/profile gamut mesh.
@Published var profileMesh: GamutMesh? var profileMesh: GamutMesh?
/// User-facing status line. /// User-facing status line.
@Published var status = "Loading gamut…" var status = "Loading gamut…"
/// Closure injected into the SceneKit view to request a camera reset. /// Closure injected into the SceneKit view to request a camera reset.
@Published var resetCamera: () -> Void = {} var resetCamera: () -> Void = {}
private let profileGamURL: URL? private let profileGamURL: URL?
+10 -26
View File
@@ -5,11 +5,11 @@ import SwiftUI
@main @main
struct ICCeryApp: App { struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var workflow: TargetWorkflowViewModel @State private var workflow: TargetWorkflowViewModel
init() { init() {
let environment = AppEnvironment.live() let environment = AppEnvironment.live()
_workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment)) _workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
try? AppPaths.ensureDirectories() try? AppPaths.ensureDirectories()
// Log level is runtime state apply persisted settings at // Log level is runtime state apply persisted settings at
// startup (#158); the Settings sheet re-applies on save. // startup (#158); the Settings sheet re-applies on save.
@@ -17,17 +17,15 @@ struct ICCeryApp: App {
} }
var body: some Scene { var body: some Scene {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700); // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
// metrics are applied by AppDelegate once the window exists. Window("ICCery", id: "main") {
WindowGroup("ICCery") {
RootView(workflow: workflow) RootView(workflow: workflow)
.frame(minWidth: 1100, minHeight: 700) .frame(minWidth: 1100, minHeight: 700)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
} }
.commands { .defaultSize(width: 1280, height: 800)
// Single-window app: no File > New window. .windowResizability(.contentMinSize)
CommandGroup(replacing: .newItem) {} .defaultPosition(.center)
}
} }
} }
@@ -39,30 +37,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false private var terminationRequested = false
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
// SwiftUI scenes launched by XCTest stay `.runningBackground` // SwiftUI `Window` scenes launched by XCTest stay
// unless the app takes regular activation and orders the window // `.runningBackground` unless the app takes regular activation
// front (CI run 29804). // and orders the window front (CI run 29804).
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
for window in NSApp.windows { for window in NSApp.windows {
configureMainWindow(window)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
} }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
private func configureMainWindow(_ window: NSWindow) {
window.setContentSize(NSSize(width: 1280, height: 800))
window.contentMinSize = NSSize(width: 1100, height: 700)
window.center()
}
/// Dock-click reopen: let the WindowGroup re-show or recreate the
/// main window when none are visible.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true true
} }
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
@@ -32,7 +32,8 @@ enum XYStep: Equatable, Sendable {
/// Stage 3 workflow state and interaction (issues #18#22). /// Stage 3 workflow state and interaction (issues #18#22).
@MainActor @MainActor
final class MeasurementWorkflowViewModel: ObservableObject { @Observable
final class MeasurementWorkflowViewModel {
// MARK: - Authorities // MARK: - Authorities
@@ -41,37 +42,36 @@ final class MeasurementWorkflowViewModel: ObservableObject {
// MARK: - Settings-driven thresholds // MARK: - Settings-driven thresholds
@Published private(set) var goodMax: Double = 2.0 private(set) var goodMax: Double = 2.0
@Published private(set) var warningMax: Double = 5.0 private(set) var warningMax: Double = 5.0
@Published private(set) var enableLEDs: Bool = false private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection // MARK: - Instrument detection
@Published var instruments: [InstrumentDevice] = [] var instruments: [InstrumentDevice] = []
@Published var selectedInstrument: InstrumentSelection = .auto var selectedInstrument: InstrumentSelection = .auto
@Published var isDetecting = false var isDetecting = false
@Published var detectionError: String? var detectionError: String?
// MARK: - Chartread session // MARK: - Chartread session
@Published var isChartreadRunning = false var isChartreadRunning = false
@Published var chartreadState: ChartreadState = .idle var chartreadState: ChartreadState = .idle
@Published var currentPrompt: String? var currentPrompt: String?
@Published var requestedWarningKey: String? var requestedWarningKey: String?
@Published var chartreadLog: [String] = [] var chartreadLog: [String] = []
@Published var rows: [ChartreadRow] = [] var rows: [ChartreadRow] = []
@Published var swatchRows: [SwatchRow] = [] var swatchRows: [SwatchRow] = []
@Published var showRemoveSheetNotice = false var showRemoveSheetNotice = false
/// Stage-local chartread error notice (`#chartreadLastError`, #80). var lastError: String?
@Published var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>? private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging // MARK: - Averaging
@Published var passSnapshots: [URL] = [] var passSnapshots: [URL] = []
@Published var isFinishing = false var isFinishing = false
@Published var finishNotice: Notice? var finishNotice: Notice?
@Published var resumedFromTi2 = false var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard self.wizard = wizard
@@ -185,7 +185,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
isChartreadRunning = true isChartreadRunning = true
chartreadState = .idle chartreadState = .idle
currentPrompt = nil currentPrompt = nil
chartreadNotice = nil lastError = nil
chartreadLog.removeAll() chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass. // Optional: reset rows when starting a fresh first pass.
@@ -227,10 +227,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
case .exit(let code): case .exit(let code):
if code != 0 { if code != 0 {
chartreadNotice = Notice( lastError = "chartread exited with code \(code)"
kind: .error,
text: "chartread exited with code \(code)"
)
} }
case .completed(let canonicalURL): case .completed(let canonicalURL):
@@ -238,7 +235,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
completePass(canonicalURL: canonicalURL) completePass(canonicalURL: canonicalURL)
case .failed(let error): case .failed(let error):
chartreadNotice = Notice(kind: .error, text: error.localizedDescription) lastError = error.localizedDescription
chartreadState = .error chartreadState = .error
isChartreadRunning = false isChartreadRunning = false
} }
@@ -384,10 +381,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
discoverPassSnapshots() discoverPassSnapshots()
wizard.refreshGating() wizard.refreshGating()
} catch { } catch {
chartreadNotice = Notice( lastError = "Could not snapshot pass: \(error.localizedDescription)"
kind: .error,
text: "Could not snapshot pass: \(error.localizedDescription)"
)
} }
} }
@@ -403,32 +397,30 @@ final class MeasurementWorkflowViewModel: ObservableObject {
func finishAndAverage() { func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return } guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil finishNotice = nil
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
do { do {
// No log reset: prior chartread output must be preserved. let canonical: URL
let canonical = try await ProcessRunSupport.runLogged(
setRunning: { self.isFinishing = $0 },
resetLog: {},
onLog: { self.chartreadLog.append(contentsOf: $0) }
) { onLog in
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first { if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
return try MeasurementArtefacts.promotePass( canonical = try MeasurementArtefacts.promotePass(
pass: pass, pass: pass,
basename: self.basename, basename: self.basename,
cwd: cwd cwd: cwd
) )
} } else {
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
basename: self.basename, basename: self.basename,
passFiles: self.passSnapshots passFiles: self.passSnapshots
) )
return try await self.environment.runner.runAverage( canonical = try await self.environment.runner.runAverage(
config: config, config: config,
onLogBatch: onLog onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.chartreadLog.append(contentsOf: batch)
}
) )
} }
self.discoverPassSnapshots() self.discoverPassSnapshots()
@@ -473,6 +465,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
) )
} }
} }
self.isFinishing = false
} }
} }
} }
-8
View File
@@ -21,14 +21,6 @@ struct Notice: Identifiable, Equatable {
case .error: return .red case .error: return .red
} }
} }
var accessibilityValue: String {
switch self {
case .info: return "info"
case .warning: return "warning"
case .error: return "error"
}
}
} }
let id = UUID() let id = UUID()
+2 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// `#savePresetDialog` save the live Stage 1/2 form as a custom /// `#savePresetDialog` save the live Stage 1/2 form as a custom
/// preset (issue #11). Names/descriptions render via `Text` only (#114). /// preset (issue #11). Names/descriptions render via `Text` only (#114).
struct SavePresetDialog: View { struct SavePresetDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
/// `#managePresetsDialog` list, delete (custom only), import, export. /// `#managePresetsDialog` list, delete (custom only), import, export.
struct ManagePresetsDialog: View { struct ManagePresetsDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -1,22 +1,23 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85). /// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor @MainActor
final class PrintSessionViewModel: ObservableObject { @Observable
final class PrintSessionViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@Published var printers: [Printer] = [] var printers: [Printer] = []
@Published var selectedPrinter = "" var selectedPrinter = ""
@Published var printerCaps = PrinterCapabilities() var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int? var selectedTray: Int?
@Published var selectedMediaType: String? var selectedMediaType: String?
@Published var printOrientation = "portrait" var printOrientation = "portrait"
@Published var capturedCupsOptions: [String: String] = [:] var capturedCupsOptions: [String: String] = [:]
@Published var printNotice: Notice? var printNotice: Notice?
@Published var isPrinting = false var isPrinting = false
private var printTask: Task<Void, Never>? private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -110,8 +111,7 @@ final class PrintSessionViewModel: ObservableObject {
isPrinting = true isPrinting = true
let task = Task { @MainActor [weak self] in let task = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
// `defer` cannot mutate isolated state under Swift 5.7 defer { self.printTask = nil }
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
var printed = 0 var printed = 0
for page in result.pages { for page in result.pages {
do { do {
@@ -124,7 +124,6 @@ final class PrintSessionViewModel: ObservableObject {
+ error.localizedDescription + error.localizedDescription
) )
isPrinting = false isPrinting = false
self.printTask = nil
return return
} }
} }
@@ -134,7 +133,6 @@ final class PrintSessionViewModel: ObservableObject {
autoHideAfter: nil autoHideAfter: nil
) )
isPrinting = false isPrinting = false
self.printTask = nil
} }
printTask = task printTask = task
} }
@@ -144,6 +142,7 @@ final class PrintSessionViewModel: ObservableObject {
isPrinting = true isPrinting = true
let task = Task { @MainActor [weak self] in let task = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.printTask = nil }
do { do {
try await spool(page, index: page.index, pageSize: pageSize) try await spool(page, index: page.index, pageSize: pageSize)
printNotice = Notice( printNotice = Notice(
@@ -158,7 +157,6 @@ final class PrintSessionViewModel: ObservableObject {
) )
} }
isPrinting = false isPrinting = false
self.printTask = nil
} }
printTask = task printTask = task
} }
+58 -60
View File
@@ -1,11 +1,12 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// 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
final class ProfileWorkflowViewModel: ObservableObject { @Observable
final class ProfileWorkflowViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@@ -13,50 +14,51 @@ final class ProfileWorkflowViewModel: ObservableObject {
// MARK: - Stage 4 form // MARK: - Stage 4 form
@Published var algorithm: String = "l" // l | x | X | m var algorithm: String = "l" // l | x | X | m
@Published var quality: String = "m" // l | m | h | u var quality: String = "m" // l | m | h | u
@Published var intent: String = "" // usually empty at Stage 4 var intent: String = "" // usually empty at Stage 4
@Published var fwaSelection: ColprofFwaSelection = .none var fwaSelection: ColprofFwaSelection = .none
@Published var fwaCustomPath: String = "" var fwaCustomPath: String = ""
@Published var illuminant: String = "" var illuminant: String = ""
@Published var observer: String = "" var observer: String = ""
@Published var inputViewingCond: String = "" var inputViewingCond: String = ""
@Published var outputViewingCond: String = "" var outputViewingCond: String = ""
@Published var profileDescription: String = "" var profileDescription: String = ""
@Published var copyright: String = "" var copyright: String = ""
// MARK: - Run state // MARK: - Run state
@Published var isColprofRunning = false var isColprofRunning = false
@Published var colprofLog: [String] = [] var colprofLog: [String] = []
@Published var colprofProgress: String? var colprofProgress: String?
@Published var createdProfileURL: URL? var lastError: String?
var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
@Published var createdGamutURL: URL? var createdGamutURL: URL?
// MARK: - Stage 4/5 calibration (issue #24) // MARK: - Stage 4/5 calibration (issue #24)
@Published var applyCalibration = false var applyCalibration = false
@Published var calibrationFile: String = "" var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25) // MARK: - Stage 5 verification (issue #25)
@Published var profcheckReport: ProfcheckReport? var profcheckReport: ProfcheckReport?
@Published var profcheckWarning: String? var profcheckWarning: String?
@Published var isProfcheckRunning = false var isProfcheckRunning = false
// MARK: - History / drift (issue #26) // MARK: - History / drift (issue #26)
@Published var verificationHistory: [VerificationRecord] = [] var verificationHistory: [VerificationRecord] = []
@Published var driftPrinterFilter: String? = nil var driftPrinterFilter: String? = nil
@Published var driftAlert: String? var driftAlert: String?
@Published var isHistoryStoreError: String? var isHistoryStoreError: String?
// MARK: - Install (issue #27) // MARK: - Install (issue #27)
@Published var installResult: InstallProfileResult? var installResult: InstallProfileResult?
@Published var showingInstallCollision = false var showingInstallCollision = false
@Published var installCollisionMessage: String = "" var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions? var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -163,25 +165,26 @@ final class ProfileWorkflowViewModel: ObservableObject {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return } guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig() let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil colprofProgress = nil
lastError = nil
createdProfileURL = nil createdProfileURL = nil
createdGamutURL = nil createdGamutURL = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.isColprofRunning = false }
do { do {
let outcome = try await ProcessRunSupport.runLogged( let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
setRunning: { self.isColprofRunning = $0 }, guard let self else { return }
resetLog: { self.colprofLog = [] },
onLog: { batch in
self.colprofLog.append(contentsOf: batch) self.colprofLog.append(contentsOf: batch)
if let last = batch.last { if let last = batch.last {
self.updateProgress(ColprofProgressClassifier.classify(line: last)) self.updateProgress(ColprofProgressClassifier.classify(line: last))
} }
} })
) { onLog in
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
var finalProfileURL = url var finalProfileURL = url
@@ -196,26 +199,26 @@ final class ProfileWorkflowViewModel: ObservableObject {
} }
// Gamut extraction is best-effort for Stage 5 / M6 viewer. // Gamut extraction is best-effort for Stage 5 / M6 viewer.
var gamutURL: URL?
do { do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL) let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog) let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
gamutURL = url self?.colprofLog.append(contentsOf: batch)
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)") })
self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch { } catch {
self.wizard.showNotice( self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)", "Gamut extraction skipped: \(error.localizedDescription)",
kind: .info kind: .info
) )
} }
return (profileURL: finalProfileURL, gamutURL: gamutURL)
} self.createdProfileURL = finalProfileURL
self.createdProfileURL = outcome.profileURL
self.createdGamutURL = outcome.gamutURL
self.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)") self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall) self.wizard.go(to: .verifyInstall)
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)", "Profile creation failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -282,28 +285,23 @@ final class ProfileWorkflowViewModel: ObservableObject {
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd) let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL) let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil profcheckReport = nil
profcheckWarning = nil profcheckWarning = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.isProfcheckRunning = false }
do { do {
let outcome = try await ProcessRunSupport.runLogged( let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
setRunning: { self.isProfcheckRunning = $0 }, self?.colprofLog.append(contentsOf: batch)
resetLog: {}, })
onLog: { self.colprofLog.append(contentsOf: $0) } self.profcheckReport = report
) { onLog in
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
var history: [VerificationRecord]?
if let record = self.makeVerificationRecord(from: report) { if let record = self.makeVerificationRecord(from: report) {
history = try await self.environment.historyStore.append(record) let updated = try await self.environment.historyStore.append(record)
} self.verificationHistory = updated
return (report: report, history: history)
}
self.profcheckReport = outcome.report
if let history = outcome.history {
self.verificationHistory = history
self.driftAlert = DriftAlert.compute(from: self.filteredHistory) self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
} }
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable { } catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
+5 -11
View File
@@ -5,18 +5,12 @@ import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification /// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell). /// banner pinned to the top (docs/21 §Shell).
struct RootView: View { struct RootView: View {
@ObservedObject var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@State private var showingSettings = false @State private var showingSettings = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingAllHelp = false @State private var showingAllHelp = false
init(workflow: TargetWorkflowViewModel) { private var model: WizardViewModel { workflow.wizard }
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -70,11 +64,11 @@ struct RootView: View {
} }
/// Content for the active wizard stage. Isolated into its own view so that /// Content for the active wizard stage. Isolated into its own view so that
/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's /// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations. /// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View { private struct WizardStageContent: View {
@ObservedObject var model: WizardViewModel @Bindable var model: WizardViewModel
@ObservedObject var workflow: TargetWorkflowViewModel var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
switch model.stage { switch model.stage {
+2 -1
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with /// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
/// the full v1 field set; ΔE validation shows inline under the fields. /// the full v1 field set; ΔE validation shows inline under the fields.
struct SettingsView: View { struct SettingsView: View {
@StateObject var model = SettingsViewModel() @State var model = SettingsViewModel()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
private static let instruments: [(code: String, label: String)] = [ private static let instruments: [(code: String, label: String)] = [
@@ -143,6 +143,7 @@ struct SettingsView: View {
} }
} }
} }
.formStyle(.grouped)
Divider() Divider()
+6 -6
View File
@@ -1,5 +1,4 @@
import AppKit import AppKit
import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
@@ -7,11 +6,12 @@ import ICCeryCore
/// validation; the log level is applied live via `LogSink` (#158) and a /// validation; the log level is applied live via `LogSink` (#158) and a
/// `settingsDidChange` notification fans out to #20. /// `settingsDidChange` notification fans out to #20.
@MainActor @MainActor
final class SettingsViewModel: ObservableObject { @Observable
final class SettingsViewModel {
@Published var settings: AppSettings var settings: AppSettings
@Published var validationErrors: [String] = [] var validationErrors: [String] = []
@Published var savedFlash = false var savedFlash = false
private let store: SettingsStore private let store: SettingsStore
private let sink: LogSink private let sink: LogSink
@@ -33,7 +33,7 @@ final class SettingsViewModel: ObservableObject {
sink.applySettings(settings) sink.applySettings(settings)
savedFlash = true savedFlash = true
Task { Task {
try? await Task.sleep(nanoseconds: 1_500_000_000) try? await Task.sleep(for: .seconds(1.5))
savedFlash = false savedFlash = false
} }
return true return true
+2 -18
View File
@@ -4,28 +4,12 @@ import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset /// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 15 stepper. /// select, Calibrate Printer + status chip, and the 15 stepper.
struct SidebarView: View { struct SidebarView: View {
@ObservedObject var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var profile: ProfileWorkflowViewModel
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenAbout: () -> Void var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool @Binding var showingAllHelp: Bool
init( private var model: WizardViewModel { workflow.wizard }
workflow: TargetWorkflowViewModel,
onOpenSettings: @escaping () -> Void,
onOpenAbout: @escaping () -> Void,
showingAllHelp: Binding<Bool>
) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._profile = ObservedObject(wrappedValue: workflow.profile)
self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
+1 -1
View File
@@ -5,7 +5,7 @@ import ICCeryCore
/// docs/08). All documented element ids are wired as accessibility /// docs/08). All documented element ids are wired as accessibility
/// identifiers so the UI-test contract stays stable. /// identifiers so the UI-test contract stays stable.
struct Stage1View: View { struct Stage1View: View {
@ObservedObject var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
ScrollView { ScrollView {
+6 -37
View File
@@ -5,19 +5,7 @@ import ICCeryCore
/// issues #9/#10, docs/09). Print controls are visible but inert /// issues #9/#10, docs/09). Print controls are visible but inert
/// real spooling lands in M3. /// real spooling lands in M3.
struct Stage2View: View { struct Stage2View: View {
@ObservedObject var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var wizard: WizardViewModel
@State private var printGenerationTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -230,7 +218,6 @@ struct Stage2View: View {
.foregroundStyle(notice.kind == .error .foregroundStyle(notice.kind == .error
? .red : .blue) ? .red : .blue)
.accessibilityIdentifier("printNotificationIcon") .accessibilityIdentifier("printNotificationIcon")
.accessibilityValue(notice.kind.accessibilityValue)
Text(notice.text) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(notice.kind == .error .foregroundStyle(notice.kind == .error
@@ -252,7 +239,7 @@ struct Stage2View: View {
} }
.frame(maxWidth: 320) .frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect") .accessibilityIdentifier("printerSelect")
.onChange(of: workflow.print.selectedPrinter) { _ in .onChange(of: workflow.print.selectedPrinter) { _, _ in
workflow.print.selectedTray = nil workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() } Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
@@ -340,18 +327,9 @@ struct Stage2View: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel") .accessibilityIdentifier("rawPrintPanel")
.onAppear { schedulePrinterRefresh() } .task(id: workflow.printtargResult?.pages.count) {
.onChange(of: workflow.printtargResult?.pages.count) { _ in // Auto-enumerate once a manifest exists and whenever it
schedulePrinterRefresh() // changes (e.g. resume from .ti2).
}
}
/// Auto-enumerates printers once a manifest exists and whenever it
/// changes (e.g. resume from .ti2). The explicit task handle keeps
/// a superseded run from racing the next one.
private func schedulePrinterRefresh() {
printGenerationTask?.cancel()
printGenerationTask = Task { @MainActor in
if workflow.print.printers.isEmpty, workflow.printtargResult != nil { if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.print.refreshPrinters() workflow.print.refreshPrinters()
} }
@@ -362,16 +340,7 @@ struct Stage2View: View {
/// One gallery cell: PNG preview + per-page Print button. /// One gallery cell: PNG preview + per-page Print button.
private struct GalleryPageView: View { private struct GalleryPageView: View {
let page: GalleryPage let page: GalleryPage
@ObservedObject var workflow: TargetWorkflowViewModel let workflow: TargetWorkflowViewModel
/// Observed directly: `print` is a nested ObservableObject and its
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
@ObservedObject private var printSession: PrintSessionViewModel
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
self.page = page
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
var body: some View { var body: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
+16 -20
View File
@@ -4,15 +4,7 @@ import ICCeryCore
/// Stage 3 measurement, live swatches, and multi-pass averaging. /// Stage 3 measurement, live swatches, and multi-pass averaging.
struct Stage3View: View { struct Stage3View: View {
@ObservedObject var model: MeasurementWorkflowViewModel @Bindable var model: MeasurementWorkflowViewModel
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
/// observe it directly so header updates propagate.
@ObservedObject private var wizard: WizardViewModel
init(model: MeasurementWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -172,22 +164,28 @@ struct Stage3View: View {
.foregroundStyle(Theme.accent) .foregroundStyle(Theme.accent)
} }
if let notice = model.chartreadNotice { if let lastError = model.lastError {
Text(notice.text) Text(lastError)
.font(.caption) .font(.caption)
.foregroundStyle(notice.kind.tint) .foregroundStyle(.red)
.accessibilityIdentifier("chartreadLastError") .accessibilityIdentifier("chartreadLastError")
.accessibilityValue(notice.text) .accessibilityValue(lastError)
} }
controlButtons controlButtons
if !model.chartreadLog.isEmpty { if !model.chartreadLog.isEmpty {
ProcessLogView( DisclosureGroup("Log") {
lines: model.chartreadLog, VStack(alignment: .leading) {
containerId: "chartreadLogContainer", ForEach(model.chartreadLog, id: \.self) { line in
logId: "chartreadLog" Text(line)
) .font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
} }
} }
.padding(16) .padding(16)
@@ -376,8 +374,6 @@ struct Stage3View: View {
Text(notice.text) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(notice.kind == .error ? .red : .green) .foregroundStyle(notice.kind == .error ? .red : .green)
.accessibilityIdentifier("chartreadFinishNotice")
.accessibilityValue(notice.kind.accessibilityValue)
} }
} }
.padding(16) .padding(16)
+19 -18
View File
@@ -3,15 +3,7 @@ import ICCeryCore
/// Stage 4 build an ICC/ICM profile from the canonical `.ti3`. /// Stage 4 build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View { struct Stage4View: View {
@ObservedObject var model: ProfileWorkflowViewModel @Bindable var model: ProfileWorkflowViewModel
/// Header reads `model.wizard.basename`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -138,9 +130,6 @@ struct Stage4View: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCopyright") .accessibilityIdentifier("colprofCopyright")
// Nested VStack keeps the parent at the Swift 5.7 ViewBuilder
// 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111).
VStack(alignment: .leading, spacing: 12) {
Toggle("Apply calibration curve", isOn: $model.applyCalibration) Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration") .accessibilityIdentifier("colprofApplyCalibration")
@@ -154,7 +143,6 @@ struct Stage4View: View {
} }
} }
} }
}
.padding(16) .padding(16)
.background(Theme.panel) .background(Theme.panel)
} }
@@ -178,14 +166,27 @@ struct Stage4View: View {
} }
Spacer() Spacer()
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("colprofLastError")
}
} }
if !model.colprofLog.isEmpty { if !model.colprofLog.isEmpty {
ProcessLogView( DisclosureGroup("Log") {
lines: model.colprofLog, VStack(alignment: .leading) {
containerId: "colprofLogContainer", ForEach(model.colprofLog, id: \.self) { line in
logId: "colprofLog" Text(line)
) .font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
} }
} }
.padding(16) .padding(16)
+1 -9
View File
@@ -3,15 +3,7 @@ import ICCeryCore
/// Stage 5 verify the generated profile, track drift, and install. /// Stage 5 verify the generated profile, track drift, and install.
struct Stage5View: View { struct Stage5View: View {
@ObservedObject var model: ProfileWorkflowViewModel @Bindable var model: ProfileWorkflowViewModel
/// Header/buttons read `model.wizard.*`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+64 -63
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset /// Stage 1/2 form state, runner orchestration, resume flow, and preset
@@ -10,7 +10,8 @@ import ICCeryCore
/// All process work runs through `ArgyllRunner` off `@MainActor`; only /// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back. /// coalesced log batches and completion hop back.
@MainActor @MainActor
final class TargetWorkflowViewModel: ObservableObject { @Observable
final class TargetWorkflowViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@@ -18,95 +19,95 @@ final class TargetWorkflowViewModel: ObservableObject {
// MARK: - Stage 1 form (targen) // MARK: - Stage 1 form (targen)
@Published var colourSpace: ColourSpace = .rgb { var colourSpace: ColourSpace = .rgb {
didSet { didSet {
guard colourSpace != oldValue else { return } guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08). // CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4 blackPatches = colourSpace == .cmyk ? 0 : 4
} }
} }
@Published var patchPreset: PatchCountPreset = .standard800 var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` used when `patchPreset == .custom`. /// `#patchCountCustom` used when `patchPreset == .custom`.
@Published var customPatchCount = 2500 var customPatchCount = 2500
@Published var whitePatches = 4 var whitePatches = 4
@Published var blackPatches = 4 var blackPatches = 4
// Advanced each optional flag is enabled + value, so an untouched // Advanced each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in). // control emits nothing (#advanced fields are opt-in).
@Published var greyStepsEnabled = false var greyStepsEnabled = false
@Published var greySteps = 5 var greySteps = 5
@Published var singleChannelEnabled = false var singleChannelEnabled = false
@Published var singleChannelSteps = 5 var singleChannelSteps = 5
@Published var neutralStepsEnabled = false var neutralStepsEnabled = false
@Published var neutralSteps = 3 var neutralSteps = 3
@Published var neutralConcEnabled = false var neutralConcEnabled = false
@Published var neutralConcentration = 0.50 var neutralConcentration = 0.50
@Published var preconditioningProfile: String? var preconditioningProfile: String?
@Published var highQuality = false var highQuality = false
@Published var adaptationEnabled = false var adaptationEnabled = false
@Published var adaptation = 0.10 var adaptation = 0.10
@Published var algorithm: FullSpreadAlgorithm = .ofps var algorithm: FullSpreadAlgorithm = .ofps
@Published var inkLimitEnabled = false var inkLimitEnabled = false
@Published var totalInkLimit = 320 var totalInkLimit = 320
@Published var darkEmphasisEnabled = false var darkEmphasisEnabled = false
@Published var darkEmphasis = 1.0 var darkEmphasis = 1.0
@Published var devicePowerEnabled = false var devicePowerEnabled = false
@Published var devicePower = 1.0 var devicePower = 1.0
/// `#targetBasename` no placeholder is ever invented (#60). /// `#targetBasename` no placeholder is ever invented (#60).
@Published var targetBasename = "" var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd. /// `#selectedPathDisplay` / resolved cwd.
@Published var targetDirectory: URL? var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg) // MARK: - Stage 2 form (printtarg)
@Published var instrument: PrintInstrument = .i1 var instrument: PrintInstrument = .i1
@Published var pageSize: PageSize = .a4 var pageSize: PageSize = .a4
@Published var customPageW = 210.0 var customPageW = 210.0
@Published var customPageH = 297.0 var customPageH = 297.0
@Published var bitDepth: TiffBitDepth = .eight var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` two-way bound; presets can change it (150-DPI draft /// `#tiffDpi` two-way bound; presets can change it (150-DPI draft
/// regression must be visible here). /// regression must be visible here).
@Published var tiffDpi = 300 var tiffDpi = 300
@Published var layoutOrder: LayoutOrder = .deterministic var layoutOrder: LayoutOrder = .deterministic
@Published var customSeed = 1 var customSeed = 1
@Published var labelIsCustom = false var labelIsCustom = false
@Published var customLabel = "" var customLabel = ""
@Published var metaPrinter = "" var metaPrinter = ""
@Published var metaInkSet = "" var metaInkSet = ""
@Published var metaDriverPaper = "" var metaDriverPaper = ""
@Published var metaActualPaper = "" var metaActualPaper = ""
// MARK: - Run state // MARK: - Run state
@Published var targenRunning = false var targenRunning = false
@Published var targenLog: [String] = [] var targenLog: [String] = []
@Published var printtargRunning = false var printtargRunning = false
@Published var printtargLog: [String] = [] var printtargLog: [String] = []
@Published var printtargResult: PrinttargResult? var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on /// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data). /// Stage 3 (`#stage3LoadedTargetBanner` data).
@Published var resumedFromTi2 = false var resumedFromTi2 = false
// MARK: - Presets // MARK: - Presets
@Published var presets: [ProfilingPreset] = [] var presets: [ProfilingPreset] = []
@Published var selectedPresetID = "none" var selectedPresetID = "none"
@Published var showingSavePreset = false var showingSavePreset = false
@Published var showingManagePresets = false var showingManagePresets = false
@Published var savePresetName = "" var savePresetName = ""
@Published var savePresetDesc = "" var savePresetDesc = ""
/// Stage 3 measurement workflow, owned at the app level so it persists /// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes. /// across stage switches and can observe settings changes.
@Published var measurement: MeasurementWorkflowViewModel var measurement: MeasurementWorkflowViewModel
/// Stage 4/5 profile workflow, owned at the app level so it persists /// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can observe preset values. /// across stage switches and can observe preset values.
@Published var profile: ProfileWorkflowViewModel var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow. /// Stage 0 calibration workflow.
@Published var calibration: CalibrationViewModel! var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session. /// Stage 2 unmanaged print session.
@Published var print: PrintSessionViewModel! var print: PrintSessionViewModel!
init(environment: AppEnvironment = .live()) { init(environment: AppEnvironment = .live()) {
self.environment = environment self.environment = environment
@@ -205,6 +206,8 @@ final class TargetWorkflowViewModel: ObservableObject {
func generateTarget() { func generateTarget() {
guard canGenerate, !targenRunning else { return } guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig() let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false resumedFromTi2 = false
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
@@ -225,6 +228,7 @@ final class TargetWorkflowViewModel: ObservableObject {
} catch { } catch {
wizard.showNotice( wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error) "targen failed: \(error.localizedDescription)", kind: .error)
targenRunning = false
} }
} }
} }
@@ -238,12 +242,7 @@ final class TargetWorkflowViewModel: ObservableObject {
? UITestHooks.datasetImportURL ? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile() : fileDialogs.selectDatasetFile()
guard let url else { return } guard let url else { return }
importMeasurementDataset(from: url)
}
/// Test seam (issue #80): unit tests pass missing or malformed URLs
/// directly instead of mutating the global environment.
func importMeasurementDataset(from url: URL) {
do { do {
let dataset = try CGATSParser.parse(url: url) let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else { guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
@@ -340,6 +339,8 @@ final class TargetWorkflowViewModel: ObservableObject {
func createLayout() { func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return } guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig() let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil printtargResult = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
+15 -14
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06). /// Wizard state machine + artefact gating (issue #4, docs/06).
@@ -10,46 +10,47 @@ import ICCeryCore
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` /// `wizard_state.json`; unlocks come from `ArtefactProbe.verify`
/// navigation is disk, not buttons. /// navigation is disk, not buttons.
@MainActor @MainActor
final class WizardViewModel: ObservableObject { @Observable
final class WizardViewModel {
// MARK: - wizardState fields (persisted) // MARK: - wizardState fields (persisted)
@Published var stage: WizardStage { var stage: WizardStage {
didSet { if stage != oldValue { persist() } } didSet { if stage != oldValue { persist() } }
} }
/// `wizardState.basename` empty until a real artefact names it (#60). /// `wizardState.basename` empty until a real artefact names it (#60).
@Published var basename: String { var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } } didSet { if basename != oldValue { refreshGating(); persist() } }
} }
/// `wizardState.cwd` resolved via `resolveSafeCwd` (#59). /// `wizardState.cwd` resolved via `resolveSafeCwd` (#59).
@Published var workingDirectory: URL? { var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } } didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
} }
@Published var printerName: String? { var printerName: String? {
didSet { if printerName != oldValue { persist() } } didSet { if printerName != oldValue { persist() } }
} }
@Published var sessionMode: SessionMode { var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } } didSet { if sessionMode != oldValue { persist() } }
} }
/// `profileBasename` may differ after a `.ti3` import (#94). /// `profileBasename` may differ after a `.ti3` import (#94).
@Published var profileBasename: String? { var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } } didSet { if profileBasename != oldValue { persist() } }
} }
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29). /// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
@Published var calibrationOriginalBasename: String { var calibrationOriginalBasename: String {
didSet { if calibrationOriginalBasename != oldValue { persist() } } didSet { if calibrationOriginalBasename != oldValue { persist() } }
} }
// MARK: - Ephemeral // MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`). /// Banner notice currently displayed (`#wizardNotification`).
@Published var notice: Notice? var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`. /// Current artefact probe result; recomputed on `refreshGating()`.
@Published private(set) var artefacts = StageArtefacts() private(set) var artefacts = StageArtefacts()
/// Whether the 3D gamut viewer sheet is open (issue #28). /// Whether the 3D gamut viewer sheet is open (issue #28).
@Published var showingGamutViewer = false var showingGamutViewer = false
/// Optional `.gam` URL to show alongside the sRGB reference. /// Optional `.gam` URL to show alongside the sRGB reference.
@Published var gamutProfileURL: URL? var gamutProfileURL: URL?
private let stateStore: WizardStateStore private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>? private var noticeDismissTask: Task<Void, Never>?
@@ -200,7 +201,7 @@ final class WizardViewModel: ObservableObject {
self.notice = notice self.notice = notice
if let delay = notice.autoHideAfter { if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in noticeDismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id { if self?.notice?.id == notice.id {
self?.notice = nil self?.notice = nil
+15 -13
View File
@@ -1,25 +1,27 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
final class AppPathsTests: XCTestCase { @Suite("AppPaths")
func testAppDataDirUsesBundleID() { struct AppPathsTests {
XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2")) @Test func appDataDirUsesBundleID() {
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
} }
func testLogFileIsUnderLibraryLogs() { @Test func logFileIsUnderLibraryLogs() {
XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log") #expect(AppPaths.logFile.lastPathComponent == "iccery.log")
XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2")) #expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
} }
func testBundledArgyllDirIsInsideResources() { @Test func bundledArgyllDirIsInsideResources() {
XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll") #expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
} }
} }
final class WizardStageTests: XCTestCase { @Suite("WizardStage")
func testStepperOrderIsOneThroughFive() { struct WizardStageTests {
XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5]) @Test func stepperOrderIsOneThroughFive() {
XCTAssertNil(WizardStage.calibrate.stepperIndex) #expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
#expect(WizardStage.calibrate.stepperIndex == nil)
} }
} }
@@ -1,19 +1,22 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class ApplycalArgsTests: XCTestCase { @Suite("ApplycalArgs")
struct ApplycalArgsTests {
func testApplyArgv() throws { @Test("Apply argv")
func applyArgv() throws {
let config = ApplycalConfig( let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal", calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc") inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
) )
let args = try ApplycalArgs.build(config: config) let args = try ApplycalArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"]) #expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
} }
func testUnapplyEmittedWhenConfigSet() throws { @Test("Unapply is emitted when the caller explicitly sets it")
func unapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig( let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal", calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"), inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
@@ -22,6 +25,6 @@ final class ApplycalArgsTests: XCTestCase {
let args = try ApplycalArgs.build(config: config) let args = try ApplycalArgs.build(config: config)
// Builder emits -u only when the caller explicitly sets unapply. // Builder emits -u only when the caller explicitly sets unapply.
// The UI layer never passes unapply: true in v2.0. // The UI layer never passes unapply: true in v2.0.
XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"]) #expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
} }
} }
@@ -1,80 +0,0 @@
import XCTest
import Foundation
@testable import ICCeryCore
final class ArgsBuilderTests: XCTestCase {
// MARK: - option
func testOptionNil() {
XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
}
func testOptionPresent() {
XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
}
// MARK: - optionIfNonEmpty
func testOptionIfNonEmptyNilEmpty() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
}
func testOptionIfNonEmptyWhitespace() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
}
func testOptionIfNonEmptyTrims() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
}
// MARK: - optionUnlessApprox
func testOptionUnlessApproxNil() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
}
func testOptionUnlessApproxExactSkip() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
}
func testOptionUnlessApproxWithinEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
}
func testOptionUnlessApproxOutsideEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
}
func testOptionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal.
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
}
func testOptionUnlessApproxCustom() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"])
}
// MARK: - flag
func testFlagTrue() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
}
func testFlagFalse() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
}
}
@@ -1,16 +1,17 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class ArgyllRunnerCalibrationTests: XCTestCase { @Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner { private func makeRunner() -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() .deletingLastPathComponent()
.deletingLastPathComponent() .deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin") .appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner( return ArgyllRunner(
processManager: processManager, processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir) binaryResolver: BinaryResolver(overrideDir: binDir)
) )
} }
@@ -22,7 +23,8 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
return root return root
} }
func testCalibrationTargenProducesTi1() async throws { @Test("Calibration targen produces CAL_*.ti1")
func calibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let runner = makeRunner() let runner = makeRunner()
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
@@ -34,16 +36,16 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
let url = try await runner.runCalibrationTargen(config: config) let url = try await runner.runCalibrationTargen(config: config)
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1") #expect(url.lastPathComponent == "CAL_demo.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) #expect(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
func testCalibrationTargenProcessId() async throws { @Test("Calibration targen from foo runs as process id targen_CAL_foo")
func calibrationTargenProcessId() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let pm = ProcessManager() let runner = makeRunner()
let runner = makeRunner(processManager: pm) let events = ProcessManager.shared.events()
let events = pm.events()
// Subscribed before spawn; the exit event is emitted before // Subscribed before spawn; the exit event is emitted before
// runCalibrationTargen returns, so this always terminates. // runCalibrationTargen returns, so this always terminates.
let sawExit = Task { let sawExit = Task {
@@ -62,13 +64,13 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
let url = try await runner.runCalibrationTargen(config: config) let url = try await runner.runCalibrationTargen(config: config)
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1") #expect(url.lastPathComponent == "CAL_foo.ti1")
let sawExitEvent = await sawExit.value #expect(await sawExit.value)
XCTAssertTrue(sawExitEvent)
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
func testPrintcalProducesCal() async throws { @Test("printcal captured run creates .cal")
func printcalProducesCal() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let runner = makeRunner() let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal") let output = testRoot.appendingPathComponent("CAL_demo.cal")
@@ -80,32 +82,15 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
let url = try await runner.runPrintcal(config: config) let url = try await runner.runPrintcal(config: config)
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal") #expect(url.lastPathComponent == "CAL_demo.cal")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) #expect(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
func testPrintcalFailureThrows() async throws { @Test("printcal failure throws printcalFailed")
func printcalFailureThrows() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
defer { try? FileManager.default.removeItem(at: testRoot) } let runner = makeRunner()
// Per-test mock printcal that always fails no global
// environment mutation, no shared fixture changes.
let binDir = try makeTestDir()
defer { try? FileManager.default.removeItem(at: binDir) }
let mockURL = binDir.appendingPathComponent("printcal")
try """
#!/bin/sh
echo "printcal mock failure" >&2
exit 1
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)
)
let output = testRoot.appendingPathComponent("CAL_demo.cal") let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "CAL_demo", ti3Basename: "CAL_demo",
@@ -113,11 +98,12 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
outputURL: output outputURL: output
) )
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
await #expect(throws: (any Error).self) {
_ = try await runner.runPrintcal(config: config) _ = try await runner.runPrintcal(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
} }
try? FileManager.default.removeItem(at: testRoot)
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class LogHolder: @unchecked Sendable { final class LogHolder: @unchecked Sendable {
@@ -19,9 +19,11 @@ final class LogHolder: @unchecked Sendable {
} }
} }
final class ArgyllRunnerColprofTests: XCTestCase { @Suite("ArgyllRunner colprof")
struct ArgyllRunnerColprofTests {
func testColprofProducesIcc() async throws { @Test("Mock colprof produces .icc")
func colprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() .deletingLastPathComponent()
.deletingLastPathComponent() .deletingLastPathComponent()
@@ -31,7 +33,7 @@ final class ArgyllRunnerColprofTests: XCTestCase {
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir) binaryResolver: BinaryResolver(overrideDir: binDir)
) )
@@ -41,39 +43,10 @@ final class ArgyllRunnerColprofTests: XCTestCase {
holder.append(batch) holder.append(batch)
} }
XCTAssertEqual(url.lastPathComponent, "testrun.icc") #expect(url.lastPathComponent == "testrun.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) #expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") }) #expect(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
func testColprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let mockURL = dir.appendingPathComponent("colprof")
try """
#!/bin/sh
echo "colprof broke" >&2
exit 4
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)
)
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runColprof(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"]))
}
}
} }
@@ -1,158 +0,0 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Focused contracts for the shared `runStreamingTool` loop (#79).
///
/// Every test uses a per-test temporary directory, unique basenames,
/// and a fresh `ProcessManager` no shared UI fixture scripts and no
/// process-environment mutation.
final class ArgyllRunnerStreamingLoopTests: XCTestCase {
private func makeTempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("runner-loop-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func writeMock(_ name: String, _ body: String, in dir: URL) throws {
let url = dir.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
private func makeRunner(binDir: URL) -> ArgyllRunner {
ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
}
func testNonZeroExitThrowsToolFailed() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
echo "Generating patches..."
echo "targen: too few patches" >&2
exit 3
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "fail", workingDirectory: dir)
do {
_ = try await runner.runTargen(config: config)
XCTFail("Expected toolFailed")
} catch let error as ArgyllRunnerError {
guard case .toolFailed(let tool, let code, let logs) = error else {
XCTFail("Expected toolFailed, got \(error)")
return
}
XCTAssertEqual(tool, "targen")
XCTAssertEqual(code, 3)
XCTAssertTrue(logs.contains("Generating patches..."))
XCTAssertTrue(logs.contains("targen: too few patches"))
}
}
func testZeroExitMissingArtefact() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
echo "done but wrote nothing"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let expectedPath = dir.appendingPathComponent("gone.ti1").path
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "gone", workingDirectory: dir)
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(expectedPath))
}
}
func testImmediateExitDeliversLine() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
echo "only line"
touch "$last.ti1"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "quick", workingDirectory: dir)
let holder = LogHolder()
let url = try await runner.runTargen(config: config) { batch in
holder.append(batch)
}
XCTAssertEqual(url.lastPathComponent, "quick.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("only line"))
}
func testColprofPartialLineFlush() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
// The fragment is printed without a newline, then the mock sleeps
// past the 500 ms partial-line flush interval before writing the
// artefact and exiting so the tail is delivered mid-run.
try writeMock("colprof", """
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
printf 'Doing gamut mapping'
sleep 2
touch "$last.icc"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = ColprofConfig(basename: "frag", workingDirectory: dir)
let holder = LogHolder()
let url = try await runner.runColprof(config: config) { batch in
holder.append(batch)
}
XCTAssertEqual(url.lastPathComponent, "frag.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
}
func testToolDescriptions() {
let cases: [(tool: String, expected: String)] = [
(tool: "chartread", expected: "Chartread failed: boom"),
(tool: "average", expected: "Averaging failed: boom"),
(tool: "colprof", expected: "Profile creation failed: boom"),
(tool: "printcal", expected: "Calibration curve computation failed: boom"),
(tool: "applycal", expected: "Apply calibration failed: boom"),
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
(tool: "profcheck", expected: "Profile verification failed: boom"),
]
for (tool, expected) in cases {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
XCTAssertEqual(error.errorDescription, expected)
}
}
func testGenericFallbacks() {
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
XCTAssertEqual(unknown.errorDescription, "Process exited with code 7")
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
XCTAssertEqual(emptyLogs.errorDescription,
"Profile creation failed: exited with code 2")
}
}
+60 -46
View File
@@ -1,4 +1,4 @@
import XCTest import Testing
import Foundation import Foundation
import ImageIO import ImageIO
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -10,8 +10,9 @@ private func tempURL(_ name: String) -> URL {
.appendingPathComponent(name) .appendingPathComponent(name)
} }
final class Ti2HeaderTests: XCTestCase { @Suite("Ti2Header")
func testParsesKeywordsAndSibling() throws { struct Ti2HeaderTests {
@Test func parsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)") .appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -30,18 +31,18 @@ final class Ti2HeaderTests: XCTestCase {
) )
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2")) let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
XCTAssertEqual(h.instrument, "i1iO") #expect(h.instrument == "i1iO")
XCTAssertEqual(h.patchCount, 800) #expect(h.patchCount == 800)
XCTAssertEqual(h.pageCount, 3) #expect(h.pageCount == 3)
XCTAssertTrue(h.hasSiblingTi1) #expect(h.hasSiblingTi1)
} }
func testMissingFileYieldsEmptyHeader() { @Test func missingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2")) let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1) #expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
} }
func testNumberOfFieldsIsNotPatchCount() throws { @Test func numberOfFieldsIsNotPatchCount() throws {
let url = tempURL("t.ti2") let url = tempURL("t.ti2")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
@@ -49,11 +50,12 @@ final class Ti2HeaderTests: XCTestCase {
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write( try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8 to: url, atomically: true, encoding: .utf8
) )
XCTAssertEqual(Ti2Header.parse(url).patchCount, 52) #expect(Ti2Header.parse(url).patchCount == 52)
} }
} }
final class TiffPreviewTests: XCTestCase { @Suite("TiffPreview")
struct TiffPreviewTests {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO. /// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL { private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif") let url = tempURL("big.tif")
@@ -79,48 +81,50 @@ final class TiffPreviewTests: XCTestCase {
return url return url
} }
func testProducesCappedPNG() throws { @Test func producesCappedPNG() throws {
let tiff = try makeTiff() let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff) let png = TiffPreview.previewPNG(tiff: tiff)
XCTAssertNotNil(png) #expect(png != nil)
// PNG magic // PNG magic
XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) #expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
// Verify the cap by decoding the thumbnail header. // Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)! let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)! let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge) #expect(max(img.width, img.height) <= TiffPreview.maxEdge)
XCTAssertEqual(img.width, 1200) #expect(img.width == 1200)
} }
func testNonTiffReturnsNil() throws { @Test func nonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt") let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "hello".write(to: url, atomically: true, encoding: .utf8) try "hello".write(to: url, atomically: true, encoding: .utf8)
XCTAssertNil(TiffPreview.previewPNG(tiff: url)) #expect(TiffPreview.previewPNG(tiff: url) == nil)
} }
} }
final class ArtefactFilesTests: XCTestCase { @Suite("ArtefactFiles")
func testBase64RoundTrip() throws { struct ArtefactFilesTests {
@Test func base64RoundTrip() throws {
let url = tempURL("a.txt") let url = tempURL("a.txt")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "hello".write(to: url, atomically: true, encoding: .utf8) try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url) let b64 = try ArtefactFiles.readBase64(url)
XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8)) #expect(Data(base64Encoded: b64) == Data("hello".utf8))
} }
func testDefaultWorkingDirExists() { @Test func defaultWorkingDirExists() {
XCTAssertTrue(FileManager.default.fileExists( #expect(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path atPath: ArtefactFiles.defaultWorkingDirectory().path
)) ))
} }
} }
final class ArtefactProbeProfileTests: XCTestCase { @Suite("ArtefactProbe profile resolve")
struct ArtefactProbeProfileTests {
private func makeDir() throws -> URL { private func makeDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)") .appendingPathComponent("probe-\(UUID().uuidString)")
@@ -130,83 +134,93 @@ final class ArtefactProbeProfileTests: XCTestCase {
// MARK: Basename probe matrix (#69) // MARK: Basename probe matrix (#69)
func testOnlyIcc() throws { @Test("basename probe: only .icc exists")
func onlyIcc() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc) try Data("icc".utf8).write(to: icc)
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path) #expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path)
} }
func testOnlyIcm() throws { @Test("basename probe: only .icm exists")
func onlyIcm() throws {
let dir = try makeDir() let dir = try makeDir()
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)
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path) #expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path)
} }
func testIcmWins() throws { @Test("basename probe prefers .icm")
func icmWins() throws {
let dir = try makeDir() let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) try Data("icc".utf8).write(to: 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 url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir) let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
XCTAssertEqual(url?.path, icm.path) #expect(url?.path == icm.path)
} }
func testNeitherExists() throws { @Test("basename probe: neither exists returns nil")
func neitherExists() throws {
let dir = try makeDir() let dir = try makeDir()
XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)) #expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil)
} }
// MARK: Explicit URL matrix (#69 / #83) // MARK: Explicit URL matrix (#69 / #83)
func testExplicitIccWins() throws { @Test("explicit existing .icc wins even when .icm exists")
func explicitIccWins() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc) try Data("icc".utf8).write(to: icc)
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm")) try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path) #expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
} }
func testExplicitIcmWins() throws { @Test("explicit existing .icm wins even when .icc exists")
func explicitIcmWins() throws {
let dir = try makeDir() let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) try Data("icc".utf8).write(to: 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)
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path) #expect(ArtefactProbe.resolveProfile(icm).path == icm.path)
} }
func testFlipExtension() throws { @Test("explicit missing .icc flips to sibling .icm")
func flipExtension() throws {
let dir = try makeDir() let dir = try makeDir()
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)
XCTAssertEqual(resolved.path, icm.path) #expect(resolved.path == icm.path)
} }
func testFlipToIcc() throws { @Test("explicit missing .icm flips to sibling .icc")
func flipToIcc() throws {
let dir = try makeDir() let dir = try makeDir()
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("icc".utf8).write(to: icc) try Data("icc".utf8).write(to: icc)
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path) #expect(ArtefactProbe.resolveProfile(icm).path == icc.path)
} }
func testMissingBoth() throws { @Test("explicit missing both returns the original URL")
func missingBoth() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path) #expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
} }
func testUnrelatedExtension() throws { @Test("unrelated extension is never rewritten")
func unrelatedExtension() throws {
let dir = try makeDir() let dir = try makeDir()
let mpp = dir.appendingPathComponent("job.mpp") let mpp = dir.appendingPathComponent("job.mpp")
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc) try Data("icc".utf8).write(to: icc)
// Even though a sibling .icc exists, a missing .mpp stays .mpp. // Even though a sibling .icc exists, a missing .mpp stays .mpp.
XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path) #expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path)
let txt = dir.appendingPathComponent("job.txt") let txt = dir.appendingPathComponent("job.txt")
XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path) #expect(ArtefactProbe.resolveProfile(txt).path == txt.path)
} }
} }
+17 -16
View File
@@ -1,8 +1,9 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
final class BinaryResolverTests: XCTestCase { @Suite("BinaryResolver")
struct BinaryResolverTests {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL { private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory let root = FileManager.default.temporaryDirectory
@@ -21,16 +22,16 @@ final class BinaryResolverTests: XCTestCase {
} }
} }
func testOverrideDirWinsWhenFileExists() throws { @Test func overrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in let override = try makeTree { root in
try touch(root.appendingPathComponent("targen")) try touch(root.appendingPathComponent("targen"))
} }
let bundled = try makeTree { _ in } let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen")) #expect(r.resolve("targen") == override.appendingPathComponent("targen"))
} }
func testOverrideFallsThroughWhenMissing() throws { @Test func overrideFallsThroughWhenMissing() throws {
let override = try makeTree { _ in } let override = try makeTree { _ in }
let bundled = try makeTree { root in let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal") let dir = root.appendingPathComponent("macos-universal")
@@ -38,10 +39,10 @@ final class BinaryResolverTests: XCTestCase {
try touch(dir.appendingPathComponent("instlist")) try touch(dir.appendingPathComponent("instlist"))
} }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen")) #expect(r.resolve("targen").path.contains("macos-universal/targen"))
} }
func testUniversalPreferredWhenMarkerPresent() throws { @Test func universalPreferredWhenMarkerPresent() throws {
let bundled = try makeTree { root in let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] { for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir) let d = root.appendingPathComponent(dir)
@@ -50,10 +51,10 @@ final class BinaryResolverTests: XCTestCase {
} }
} }
let r = BinaryResolver(bundledRoot: bundled) let r = BinaryResolver(bundledRoot: bundled)
XCTAssertEqual(r.platformDir(), "macos-universal") #expect(r.platformDir() == "macos-universal")
} }
func testFallsBackToArchDir() throws { @Test func fallsBackToArchDir() throws {
let bundled = try makeTree { root in let bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64") let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
@@ -63,20 +64,20 @@ final class BinaryResolverTests: XCTestCase {
bundledRoot: bundled, bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"] archDirs: ["macos-universal", "macos-x86_64"]
) )
XCTAssertEqual(r.platformDir(), "macos-x86_64") #expect(r.platformDir() == "macos-x86_64")
} }
func testMissingEverythingReturnsConstructedPath() throws { @Test func missingEverythingReturnsConstructedPath() throws {
let bundled = try makeTree { _ in } let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled) let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error. // v1 semantic: path is returned; spawn surfaces the error.
XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen")) #expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
XCTAssertFalse(r.exists(r.resolve("targen"))) #expect(!r.exists(r.resolve("targen")))
} }
func testMockAndGamutPaths() throws { @Test func mockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x")) let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock") #expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam") #expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
} }
} }
+50 -37
View File
@@ -1,8 +1,9 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class CGATSParserTests: XCTestCase { @Suite("CGATS Parser & Writer")
struct CGATSParserTests {
private static let canonicalCTI3 = """ private static let canonicalCTI3 = """
CTI3 CTI3
@@ -20,40 +21,44 @@ final class CGATSParserTests: XCTestCase {
END_DATA END_DATA
""" """
func testParseCTI3() throws { @Test("Parses CTI3 with canonical field names")
func parseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3) let dataset = try CGATSParser.parse(Self.canonicalCTI3)
XCTAssertEqual(dataset.format, .cti3) #expect(dataset.format == .cti3)
XCTAssertEqual(dataset.samples.count, 2) #expect(dataset.samples.count == 2)
XCTAssertEqual(dataset.colorRep, "RGB") #expect(dataset.colorRep == "RGB")
XCTAssertEqual(dataset.deviceClass, "DISPLAY") #expect(dataset.deviceClass == "DISPLAY")
XCTAssertEqual(dataset.samples[0].id, "1") #expect(dataset.samples[0].id == "1")
XCTAssertEqual(dataset.samples[0].loc, "A1") #expect(dataset.samples[0].loc == "A1")
XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000") #expect(dataset.samples[1].values["RGB_G"] == "50.0000")
} }
func testRoundTrip() throws { @Test("Round-trips parse, write, reparse")
func roundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3) let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first) let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text) let second = try CGATSParser.parse(text)
XCTAssertEqual(second.format, first.format) #expect(second.format == first.format)
XCTAssertEqual(second.samples.count, first.samples.count) #expect(second.samples.count == first.samples.count)
XCTAssertEqual(second.colorRep, first.colorRep) #expect(second.colorRep == first.colorRep)
XCTAssertEqual(second.deviceClass, first.deviceClass) #expect(second.deviceClass == first.deviceClass)
} }
func testParseCSV() throws { @Test("Parses CSV with comma delimiters")
func parseCSV() throws {
let csv = """ let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30 1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40 2,A2,0,50,0,10,30,5,60,-50,40
""" """
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv")) let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
XCTAssertEqual(dataset.format, .csv) #expect(dataset.format == .csv)
XCTAssertEqual(dataset.samples.count, 2) #expect(dataset.samples.count == 2)
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000") #expect(dataset.samples[0].values["RGB_R"] == "50.0000")
} }
func testConverts255To100() throws { @Test("Converts 0-255 device values to 0-100")
func converts255To100() throws {
let rgb = """ let rgb = """
CTI3 CTI3
COLOR_REP RGB COLOR_REP RGB
@@ -67,11 +72,12 @@ final class CGATSParserTests: XCTestCase {
END_DATA END_DATA
""" """
let dataset = try CGATSParser.parse(rgb) let dataset = try CGATSParser.parse(rgb)
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000") #expect(dataset.samples[0].values["RGB_R"] == "100.0000")
XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961") #expect(dataset.samples[0].values["RGB_G"] == "50.1961")
} }
func testSynthesizesMetadata() throws { @Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
func synthesizesMetadata() throws {
let cmyk = """ let cmyk = """
CTI3 CTI3
NUMBER_OF_FIELDS 6 NUMBER_OF_FIELDS 6
@@ -84,15 +90,19 @@ final class CGATSParserTests: XCTestCase {
END_DATA END_DATA
""" """
let dataset = try CGATSParser.parse(cmyk) let dataset = try CGATSParser.parse(cmyk)
XCTAssertEqual(dataset.colorRep, "CMYK") #expect(dataset.colorRep == "CMYK")
XCTAssertEqual(dataset.deviceClass, "PRINTER") #expect(dataset.deviceClass == "PRINTER")
} }
func testRejectsEmpty() { @Test("Rejects empty file")
XCTAssertThrowsError(try CGATSParser.parse("")) func rejectsEmpty() {
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
} }
func testRejectsArity() { @Test("Rejects malformed arity")
func rejectsArity() {
let bad = """ let bad = """
CTI3 CTI3
NUMBER_OF_FIELDS 2 NUMBER_OF_FIELDS 2
@@ -104,18 +114,21 @@ final class CGATSParserTests: XCTestCase {
1 1
END_DATA END_DATA
""" """
XCTAssertThrowsError(try CGATSParser.parse(bad)) #expect(throws: (any Error).self) {
_ = try CGATSParser.parse(bad)
}
} }
func testWriterFormat() throws { @Test("Writer emits valid .ti3 with tabs and required keywords")
func writerFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3) let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset) let text = try CGATSWriter.write(dataset)
XCTAssertTrue(text.contains("CTI3")) #expect(text.contains("CTI3"))
XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT")) #expect(text.contains("BEGIN_DATA_FORMAT"))
XCTAssertTrue(text.contains("BEGIN_DATA")) #expect(text.contains("BEGIN_DATA"))
XCTAssertTrue(text.contains("END_DATA")) #expect(text.contains("END_DATA"))
XCTAssertTrue(text.contains("COLOR_REP")) #expect(text.contains("COLOR_REP"))
XCTAssertTrue(text.contains("DEVICE_CLASS")) #expect(text.contains("DEVICE_CLASS"))
XCTAssertTrue(text.contains("\t")) #expect(text.contains("\t"))
} }
} }
@@ -1,67 +1,78 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
/// Issue #83 canonical `CAL_` / original-stem pairing. /// Issue #83 canonical `CAL_` / original-stem pairing.
final class CalibrationIdentityTests: XCTestCase { @Suite("CalibrationIdentity")
func testLivePlain() { struct CalibrationIdentityTests {
@Test("live foo, no persisted")
func livePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "foo") #expect(id.originalBasename == "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo") #expect(id.calibrationBasename == "CAL_foo")
} }
func testLivePlainIgnoresPersisted() { @Test("live foo ignores stale persisted")
func livePlainIgnoresPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar") let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
XCTAssertEqual(id.originalBasename, "foo") #expect(id.originalBasename == "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo") #expect(id.calibrationBasename == "CAL_foo")
} }
func testLiveCalPersisted() { @Test("live CAL_foo, persisted foo")
func liveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
XCTAssertEqual(id.originalBasename, "foo") #expect(id.originalBasename == "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo") #expect(id.calibrationBasename == "CAL_foo")
} }
func testLiveCalNoPersist() { @Test("live CAL_foo, empty persisted strips prefix")
func liveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "foo") #expect(id.originalBasename == "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo") #expect(id.calibrationBasename == "CAL_foo")
} }
func testPersistedWins() { @Test("persisted original wins over CAL_ live")
func persistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
XCTAssertEqual(id.originalBasename, "bar") #expect(id.originalBasename == "bar")
XCTAssertEqual(id.calibrationBasename, "CAL_bar") #expect(id.calibrationBasename == "CAL_bar")
} }
func testEmptyLiveWithPersisted() { @Test("empty live yields empty identity even with persisted original")
func emptyLiveWithPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo") let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
XCTAssertTrue(id.originalBasename.isEmpty) #expect(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty) #expect(id.calibrationBasename.isEmpty)
} }
func testEmptyLive() { @Test("empty live, empty persisted")
func emptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
XCTAssertTrue(id.originalBasename.isEmpty) #expect(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty) #expect(id.calibrationBasename.isEmpty)
} }
func testAlreadyPrefixed() { @Test("prefix is idempotent on already-prefixed input")
XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo") func alreadyPrefixed() {
XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo") #expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "CAL_foo") #expect(id.originalBasename == "CAL_foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo") #expect(id.calibrationBasename == "CAL_foo")
} }
func testPrefixEmpty() { @Test("prefix never invents a name from empty input")
XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty) func prefixEmpty() {
XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo") #expect(CalibrationIdentity.prefix("").isEmpty)
XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo") #expect(CalibrationIdentity.strip("foo") == "foo")
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
} }
func testProcessIdMatches() { @Test("runner process id for a calibration targen is targen_CAL_*")
func processIdMatches() {
let cal = CalibrationIdentity.prefix("foo") let cal = CalibrationIdentity.prefix("foo")
XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo") #expect(ProcessID.targen(cal) == "targen_CAL_foo")
} }
} }
@@ -1,8 +1,9 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class CalibrationStoreTests: XCTestCase { @Suite("CalibrationStore")
struct CalibrationStoreTests {
private static let sampleCal = """ private static let sampleCal = """
CTI3 CTI3
@@ -22,7 +23,8 @@ final class CalibrationStoreTests: XCTestCase {
END_DATA END_DATA
""" """
func testParseCal() async throws { @Test("Loads metadata and curves from .cal")
func parseCal() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal") .appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -31,16 +33,17 @@ final class CalibrationStoreTests: XCTestCase {
try await store.load(url: url) try await store.load(url: url)
let data = await store.data let data = await store.data
XCTAssertEqual(data?.colorRep, "RGB") #expect(data?.colorRep == "RGB")
XCTAssertEqual(data?.descriptor, "Test printer") #expect(data?.descriptor == "Test printer")
XCTAssertEqual(data?.maxTac, 300) #expect(data?.maxTac == 300)
XCTAssertEqual(data?.curves.count, 3) #expect(data?.curves.count == 3)
let r = data?.curves.first { $0.channel == "R" } let r = data?.curves.first { $0.channel == "R" }
XCTAssertEqual(r?.output, [0, 64, 255]) #expect(r?.output == [0, 64, 255])
} }
func testStaleCalibration() async throws { @Test("Staleness is true for a very old calibration")
func staleCalibration() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal") .appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -48,10 +51,11 @@ final class CalibrationStoreTests: XCTestCase {
let store = CalibrationStore(staleDays: 0) let store = CalibrationStore(staleDays: 0)
try await store.load(url: url) try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other") let stale = await store.isStale(comparedTo: "Other")
XCTAssertEqual(stale, true) #expect(stale == true)
} }
func testPrinterMismatch() async throws { @Test("Printer mismatch is flagged as stale")
func printerMismatch() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal") .appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -60,6 +64,6 @@ final class CalibrationStoreTests: XCTestCase {
try await store.load(url: url) try await store.load(url: url)
await store.setPrinterName("Printer A") await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B") let stale = await store.isStale(comparedTo: "Printer B")
XCTAssertEqual(stale, true) #expect(stale == true)
} }
} }
@@ -1,10 +1,12 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class CalibrationTargenArgsTests: XCTestCase { @Suite("CalibrationTargenArgs")
struct CalibrationTargenArgsTests {
func testRgbBaseline() throws { @Test("RGB baseline")
func rgbBaseline() throws {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
steps: 21, steps: 21,
@@ -13,10 +15,11 @@ final class CalibrationTargenArgsTests: XCTestCase {
workingDirectory: URL(fileURLWithPath: "/tmp") workingDirectory: URL(fileURLWithPath: "/tmp")
) )
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"]) #expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
} }
func testCmykWithOptions() throws { @Test("CMYK baseline with ink limit and neutral emphasis")
func cmykWithOptions() throws {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
steps: 25, steps: 25,
@@ -27,26 +30,33 @@ final class CalibrationTargenArgsTests: XCTestCase {
workingDirectory: URL(fileURLWithPath: "/tmp") workingDirectory: URL(fileURLWithPath: "/tmp")
) )
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"]) #expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
} }
func testRejectsBadSteps() { @Test("Rejects out-of-range steps")
func rejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo") let config = CalibrationTargenConfig(steps: 5, basename: "demo")
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config)) #expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
} }
func testRejectsBadInkLimit() { @Test("Rejects bad CMYK ink limit")
func rejectsBadInkLimit() {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
inkLimit: 500, inkLimit: 500,
basename: "demo" basename: "demo"
) )
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config)) #expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
} }
func testNoDoublePrefix() throws { @Test("Does not double-prefix an existing CAL_ basename")
func noDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test") let config = CalibrationTargenConfig(basename: "CAL_test")
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
XCTAssertEqual(args.last, "CAL_test") #expect(args.last == "CAL_test")
} }
} }
+32 -23
View File
@@ -1,63 +1,72 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class ColprofArgsTests: XCTestCase { @Suite("ColprofArgs")
struct ColprofArgsTests {
func testDefaults() throws { @Test("Default algorithm and quality")
func defaults() throws {
let config = ColprofConfig(basename: "target") let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"]) #expect(args == ["-v", "-a", "l", "-q", "m", "target"])
} }
func testFwaBareFlag() throws { @Test("FWA bare -f when empty string")
func fwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target") let config = ColprofConfig(fwa: "", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"]) #expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
} }
func testFwaD50() throws { @Test("FWA D50 and D65 emit -f value")
func fwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target") let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
XCTAssertTrue(args.contains("-f")) #expect(args.contains("-f"))
XCTAssertTrue(args.contains("D50")) #expect(args.contains("D50"))
XCTAssertEqual(args.last, "target") #expect(args.last == "target")
} }
func testFwaNoneOmitted() throws { @Test("FWA none is omitted")
func fwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target") let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
XCTAssertFalse(args.contains("-f")) #expect(!args.contains("-f"))
} }
func testViewingCondNoneSkipped() throws { @Test("Viewing conditions skip none")
func viewingCondNoneSkipped() throws {
let config = ColprofConfig( let config = ColprofConfig(
inputViewingCond: "none", inputViewingCond: "none",
outputViewingCond: "mt", outputViewingCond: "mt",
basename: "target" basename: "target"
) )
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
XCTAssertFalse(args.contains("-c")) #expect(!args.contains("-c"))
XCTAssertTrue(args.contains("-d")) #expect(args.contains("-d"))
XCTAssertTrue(args.contains("mt")) #expect(args.contains("mt"))
} }
func testDescriptionFallback() throws { @Test("Description falls back to basename when empty")
func descriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target") let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
XCTAssertFalse(args.contains("-D")) #expect(!args.contains("-D"))
} }
func testCopyright() throws { @Test("Copyright only when non-empty")
func copyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target") let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
XCTAssertTrue(args.contains("-C")) #expect(args.contains("-C"))
XCTAssertTrue(args.contains("Gronod 2026")) #expect(args.contains("Gronod 2026"))
} }
func testNoProgressJsonFlag() throws { @Test("No -u passed")
func noProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target") let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
XCTAssertFalse(args.contains("-u")) #expect(!args.contains("-u"))
} }
} }
@@ -1,20 +1,24 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class ColprofProgressTests: XCTestCase { @Suite("ColprofProgress")
struct ColprofProgressTests {
func testGamutMapping() { @Test("Classifies gamut mapping")
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping) func gamutMapping() {
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
} }
func testFitting() { @Test("Classifies fitting or clut")
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut) func fitting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut) #expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
} }
func testWriting() { @Test("Classifies writing")
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc) func writing() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc) #expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
} }
} }
@@ -1,4 +1,4 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
@@ -6,42 +6,48 @@ import AppKit
import ApplicationServices import ApplicationServices
/// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ). /// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ).
final class CupsOptionsFilterTests: XCTestCase { @Suite("CupsOptionsFilter")
struct CupsOptionsFilterTests {
func testDropsReserved() { @Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
func dropsReserved() {
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching " let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching " + "AP.ColorMatchingMode=AP_ApplicationColorMatching "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 " + "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
+ "collate=true copies=1 job-sheets=none,none " + "collate=true copies=1 job-sheets=none,none "
+ "pserrorhandler-requested=standard " + "pserrorhandler-requested=standard "
+ "MediaType=PhotographicGlossy" + "MediaType=PhotographicGlossy"
XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy") #expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
} }
func testKeepsRelevant() { @Test("Keeps relevant driver keys, order preserved")
func keepsRelevant() {
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 " let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
+ "Resolution=600x600dpi Duplex=None" + "Resolution=600x600dpi Duplex=None"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) #expect(CupsOptionsFilter.filter(raw) == raw)
} }
func testKeepsUnknown() { @Test("Permissive: unknown non-com.* keys survive")
func keepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain" let raw = "VendorFooBar=baz MediaType=Plain"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) #expect(CupsOptionsFilter.filter(raw) == raw)
} }
func testDropsEmpty() { @Test("Drops empty keys and values")
func dropsEmpty() {
let raw = "=noval MediaType= InputSlot=Rear" let raw = "=noval MediaType= InputSlot=Rear"
// "MediaType=" has an empty value dropped; "=noval" empty key. // "MediaType=" has an empty value dropped; "=noval" empty key.
XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear") #expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
} }
func testExtractMedia() { @Test("extractMediaType prefers MediaType then EPIJ_Medi")
XCTAssertEqual(CupsParsers.extractMediaType( func extractMedia() {
fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo") #expect(CupsParsers.extractMediaType(
XCTAssertEqual(CupsParsers.extractMediaType( fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
fromOptionsString: "EPIJ_Medi=7"), "7") #expect(CupsParsers.extractMediaType(
XCTAssertNil(CupsParsers.extractMediaType( fromOptionsString: "EPIJ_Medi=7") == "7")
fromOptionsString: "PageSize=A4")) #expect(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4") == nil)
} }
} }
@@ -49,8 +55,9 @@ final class CupsOptionsFilterTests: XCTestCase {
/// `@convention(c)` closures can't capture, so recording goes through /// `@convention(c)` closures can't capture, so recording goes through
/// a file-scope recorder keyed by global state; no private symbols are /// a file-scope recorder keyed by global state; no private symbols are
/// touched. /// touched.
@Suite("ColorSyncSuppressor")
@MainActor @MainActor
final class ColorSyncSuppressorTests: XCTestCase { struct ColorSyncSuppressorTests {
/// Fake PMPrintSession the injected resolver never dereferences it. /// Fake PMPrintSession the injected resolver never dereferences it.
private var fakeSession: PMPrintSession { private var fakeSession: PMPrintSession {
@@ -71,14 +78,10 @@ final class ColorSyncSuppressorTests: XCTestCase {
s.modeResolver = { name in s.modeResolver = { name in
if Self.missing.contains(name) { return nil } if Self.missing.contains(name) { return nil }
Self.currentSymbol = name Self.currentSymbol = name
// `Self` inside a @convention(c) closure is a dynamic-Self
// capture spell the (final) class name instead.
return { _, modeArg in return { _, modeArg in
ColorSyncSuppressorTests.recorded.append( Self.recorded.append((Self.currentSymbol, modeArg as String))
(ColorSyncSuppressorTests.currentSymbol, modeArg as String)) if let ok = Self.succeeding,
if let ok = ColorSyncSuppressorTests.succeeding, Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
ColorSyncSuppressorTests.currentSymbol == ok.0,
(modeArg as String) == ok.1 {
return 0 return 0
} }
return 1 return 1
@@ -87,50 +90,55 @@ final class ColorSyncSuppressorTests: XCTestCase {
return s return s
} }
func testAttemptOrder() { @Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
func attemptOrder() {
Self.recorded = [] Self.recorded = []
Self.succeeding = nil Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"] Self.missing = ["PMSessionSetColorMatchingModeLock"]
let s = makeSuppressor() let s = makeSuppressor()
XCTAssertEqual(s.applySPIMode(to: fakeSession), false) #expect(s.applySPIMode(to: fakeSession) == false)
// Lock is unresolvable skipped; the rest plays out in order. // Lock is unresolvable skipped; the rest plays out in order.
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts #expect(Self.recorded.map { "\($0.0)|\($0.1)" }
== ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" } .filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" }) .map { "\($0.symbol)|\($0.mode)" })
} }
func testFirstZeroWins() { @Test("First zero wins — later symbols/modes not called")
func firstZeroWins() {
Self.recorded = [] Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock", Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"AP_ApplicationColorMatching") "AP_ApplicationColorMatching")
Self.missing = [] Self.missing = []
let s = makeSuppressor() let s = makeSuppressor()
XCTAssertTrue(s.applySPIMode(to: fakeSession)) #expect(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [ #expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
]) ])
} }
func testModeFallback() { @Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
func modeFallback() {
Self.recorded = [] Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock", Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching") "ApplicationColorMatching")
Self.missing = [] Self.missing = []
let s = makeSuppressor() let s = makeSuppressor()
XCTAssertTrue(s.applySPIMode(to: fakeSession)) #expect(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock") #expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching") #expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock") #expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching") #expect(Self.recorded[1].1 == "ApplicationColorMatching")
XCTAssertEqual(Self.recorded.count, 2) #expect(Self.recorded.count == 2)
} }
func testAllMissing() { @Test("All symbols missing → false, no calls")
func allMissing() {
Self.recorded = [] Self.recorded = []
Self.succeeding = nil Self.succeeding = nil
Self.missing = Set(ColorMatchingAttempts.symbols) Self.missing = Set(ColorMatchingAttempts.symbols)
let s = makeSuppressor() let s = makeSuppressor()
XCTAssertEqual(s.applySPIMode(to: fakeSession), false) #expect(s.applySPIMode(to: fakeSession) == false)
XCTAssertTrue(Self.recorded.isEmpty) #expect(Self.recorded.isEmpty)
} }
} }
+62 -51
View File
@@ -1,10 +1,11 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Issue 12 CUPS enumeration parsers on recorded fixtures /// Issue 12 CUPS enumeration parsers on recorded fixtures
/// (docs/1011). No live `lpstat`/`lpoptions` is spawned here. /// (docs/1011). No live `lpstat`/`lpoptions` is spawned here.
final class CupsParsersTests: XCTestCase { @Suite("CupsParsers")
struct CupsParsersTests {
// Recorded on an Epson XP-55 + Canon Pro9500 host. // Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """ private let lpstatE = """
@@ -33,102 +34,112 @@ final class CupsParsersTests: XCTestCase {
cupsPrintQuality/cupsPrintQuality: Draft *Normal High cupsPrintQuality/cupsPrintQuality: Draft *Normal High
""" """
func testDestinations() { @Test("lpstat -e: one destination per line; empty = success")
XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [ func destinations() {
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
"Canon_Pro9500_II_series_XPS", "Canon_Pro9500_II_series_XPS",
"Epson_XP_55_LPD", "Epson_XP_55_LPD",
"EPSON_XP_55_Series", "EPSON_XP_55_Series",
]) ])
XCTAssertEqual(CupsParsers.lpstatDestinations(""), []) #expect(CupsParsers.lpstatDestinations("") == [])
} }
func testStatuses() { @Test("lpstat -p: idle / now-printing / disabled statuses")
func statuses() {
let s = CupsParsers.lpstatStatuses(lpstatP) let s = CupsParsers.lpstatStatuses(lpstatP)
XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle) #expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
XCTAssertEqual(s["Epson_XP_55_LPD"], .printing) #expect(s["Epson_XP_55_LPD"] == .printing)
XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped) #expect(s["EPSON_XP_55_Series"] == .stopped)
} }
func testDefaultDestination() { @Test("lpstat -d: default destination or none")
XCTAssertEqual(CupsParsers.lpstatDefault( func defaultDestination() {
"system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS") #expect(CupsParsers.lpstatDefault(
XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n")) "system default destination: Canon_Pro9500_II_series_XPS\n")
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
} }
func testDisplayName() { @Test("lpoptions -p: quoted printer-info, bare flags ignored")
XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series") func displayName() {
XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n")) #expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
} }
func testOptionListings() { @Test("lpoptions -l: key/label split, * marks the default")
func optionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL) let listings = CupsParsers.lpoptionsList(lpoptionsL)
XCTAssertEqual(listings.count, 6) #expect(listings.count == 6)
let page = listings[0] let page = listings[0]
XCTAssertEqual(page.key, "PageSize") #expect(page.key == "PageSize")
XCTAssertEqual(page.label, "Media Size") #expect(page.label == "Media Size")
XCTAssertEqual(page.defaultChoice, "A4") #expect(page.defaultChoice == "A4")
XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT")) #expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
XCTAssertFalse(page.choices.contains("*A4")) #expect(!page.choices.contains("*A4"))
let slot = listings[1] let slot = listings[1]
XCTAssertEqual(slot.key, "InputSlot") #expect(slot.key == "InputSlot")
XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"]) #expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
XCTAssertEqual(slot.defaultChoice, "Main") #expect(slot.defaultChoice == "Main")
} }
func testCapabilities() { @Test("capabilities: trays/sizes index 1-based, media uses detected key")
func capabilities() {
let service = CupsService() let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL) let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil) let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trays, [ #expect(caps.trays == [
PrinterTray(id: 1, name: "Auto"), PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"), PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"), PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"), PrinterTray(id: 4, name: "Rear"),
]) ])
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5")) #expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
XCTAssertEqual(caps.paperSizes.count, 10) #expect(caps.paperSizes.count == 10)
XCTAssertEqual(caps.mediaTypes.map(\.id), [ #expect(caps.mediaTypes.map(\.id) == [
"Stationery", "PhotographicHighGloss", "Photographic", "Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope", "PhotographicMatte", "Envelope",
]) ])
XCTAssertTrue(caps.supportsOrientation) #expect(caps.supportsOrientation)
} }
func testPpdLabels() { @Test("PPD enrichment maps id → human label")
func ppdLabels() {
let ppd = """ let ppd = """
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>" *CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
*CNIJMediaType 0/Plain Paper: "" *CNIJMediaType 0/Plain Paper: ""
*en_US.CNIJMediaType 13/Envelope: "" *en_US.CNIJMediaType 13/Envelope: ""
""" """
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss") #expect(labels["42"] == "Photo Paper Plus Semi-gloss")
XCTAssertEqual(labels["0"], "Plain Paper") #expect(labels["0"] == "Plain Paper")
XCTAssertEqual(labels["13"], "Envelope") #expect(labels["13"] == "Envelope")
} }
func testMediaTypeKey() { @Test("detectMediaTypeKey prefers vendor keys in order")
XCTAssertEqual(CupsParsers.detectMediaTypeKey( func mediaTypeKey() {
optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType") #expect(CupsParsers.detectMediaTypeKey(
XCTAssertEqual(CupsParsers.detectMediaTypeKey( optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
optionKeys: ["PageSize", "MediaType"]), "MediaType") #expect(CupsParsers.detectMediaTypeKey(
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"])) optionKeys: ["PageSize", "MediaType"]) == "MediaType")
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
} }
func testDriverBypass() { @Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
func driverBypass() {
func pair(_ keys: Set<String>) -> String? { func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys) CupsParsers.detectDriverColorBypass(optionKeys: keys)
.map { "\($0.key)=\($0.value)" } .map { "\($0.key)=\($0.value)" }
} }
XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4") #expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4") #expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0") #expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3") #expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected") #expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected") #expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off") #expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
XCTAssertNil(pair(["PageSize"])) #expect(pair(["PageSize"]) == nil)
} }
} }
+30 -20
View File
@@ -1,57 +1,65 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class DriftAlertTests: XCTestCase { @Suite("DriftAlert")
struct DriftAlertTests {
func testNotEnough() { @Test("No alert with fewer than two poor results")
func notEnough() {
let records = [ let records = [
record(avg: 4.0, at: 1000) record(avg: 4.0, at: 1000)
] ]
XCTAssertNil(DriftAlert.compute(from: records)) #expect(DriftAlert.compute(from: records) == nil)
} }
func testOneHourApart() { @Test("Alert on two poor results one hour apart")
func oneHourApart() {
let records = [ let records = [
record(avg: 4.0, at: 1000), record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 4600) record(avg: 5.0, at: 4600)
] ]
XCTAssertNotNil(DriftAlert.compute(from: records)) #expect(DriftAlert.compute(from: records) != nil)
} }
func testSameDayUnderHour() { @Test("No alert if same day and under one hour")
func sameDayUnderHour() {
let records = [ let records = [
record(avg: 4.0, at: 1000), record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000) record(avg: 5.0, at: 2000)
] ]
XCTAssertNil(DriftAlert.compute(from: records)) #expect(DriftAlert.compute(from: records) == nil)
} }
func testDistinctDays() { @Test("Alert on distinct days")
func distinctDays() {
let day1 = record(avg: 4.0, at: 0) let day1 = record(avg: 4.0, at: 0)
let day2 = record(avg: 5.0, at: 86400 + 1000) let day2 = record(avg: 5.0, at: 86400 + 1000)
XCTAssertNotNil(DriftAlert.compute(from: [day1, day2])) #expect(DriftAlert.compute(from: [day1, day2]) != nil)
} }
func testNonPoor() { @Test("Non-poor records do not trigger")
func nonPoor() {
let records = [ let records = [
record(avg: 1.0, at: 0), record(avg: 1.0, at: 0),
record(avg: 1.5, at: 86400) record(avg: 1.5, at: 86400)
] ]
XCTAssertNil(DriftAlert.compute(from: records)) #expect(DriftAlert.compute(from: records) == nil)
} }
func testNonPoorBreaksRun() { @Test("Non-poor records break the consecutive poor run")
func nonPoorBreaksRun() {
let records = [ let records = [
record(avg: 4.0, at: 0), // poor record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good breaks the run record(avg: 1.0, at: 90000), // good breaks the run
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
] ]
XCTAssertNil(DriftAlert.compute(from: records)) #expect(DriftAlert.compute(from: records) == nil)
} }
func testOnlySuffixRun() { @Test("Only the final consecutive poor run is considered")
func onlySuffixRun() {
let records = [ let records = [
record(avg: 4.0, at: 0), // poor record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 18000), // poor, > 1h from first record(avg: 4.5, at: 18000), // poor, > 1h from first
@@ -59,24 +67,26 @@ final class DriftAlertTests: XCTestCase {
record(avg: 4.0, at: 25000), // poor record(avg: 4.0, at: 25000), // poor
record(avg: 4.5, at: 26000) // poor, < 1h and same day record(avg: 4.5, at: 26000) // poor, < 1h and same day
] ]
XCTAssertNil(DriftAlert.compute(from: records)) #expect(DriftAlert.compute(from: records) == nil)
} }
func testSuffixRunAlerts() { @Test("Final consecutive poor run alerts when far apart")
func suffixRunAlerts() {
let records = [ let records = [
record(avg: 1.0, at: 0), // good record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor record(avg: 4.0, at: 1000), // poor
record(avg: 4.5, at: 4600) // poor, 1h after previous record(avg: 4.5, at: 4600) // poor, 1h after previous
] ]
XCTAssertNotNil(DriftAlert.compute(from: records)) #expect(DriftAlert.compute(from: records) != nil)
} }
func testSingleFinalPoor() { @Test("A single final poor record after good records does not alert")
func singleFinalPoor() {
let records = [ let records = [
record(avg: 1.0, at: 0), record(avg: 1.0, at: 0),
record(avg: 4.0, at: 86400) record(avg: 4.0, at: 86400)
] ]
XCTAssertNil(DriftAlert.compute(from: records)) #expect(DriftAlert.compute(from: records) == nil)
} }
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord { private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
+38 -35
View File
@@ -1,4 +1,4 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -13,88 +13,91 @@ private func touch(_ url: URL, _ contents: String = "x") throws {
try contents.write(to: url, atomically: true, encoding: .utf8) try contents.write(to: url, atomically: true, encoding: .utf8)
} }
final class PathSecurityTests: XCTestCase { @Suite("PathSecurity")
func testRejectsTraversalAndSeparators() { struct PathSecurityTests {
@Test func rejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] { for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
XCTAssertFalse(PathSecurity.isValidBasename(bad)) #expect(!PathSecurity.isValidBasename(bad))
XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in #expect(throws: PathSecurity.Error.self) {
XCTAssertTrue(error is PathSecurity.Error) try PathSecurity.sanitizeBasename(bad)
} }
} }
} }
func testAcceptsNormalNames() { @Test func acceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] { for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
XCTAssertTrue(PathSecurity.isValidBasename(good)) #expect(PathSecurity.isValidBasename(good))
} }
} }
func testResolveSafeCwdPrefersExplicit() throws { @Test func resolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir() let dir = try tempDir()
XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir) #expect(PathSecurity.resolveSafeCwd(dir) == dir)
} }
func testResolveSafeCwdNeverReturnsNil() { @Test func resolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)") let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing) let resolved = PathSecurity.resolveSafeCwd(missing)
XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path)) #expect(FileManager.default.fileExists(atPath: resolved.path))
} }
} }
final class AtomicFileWriterTests: XCTestCase { @Suite("AtomicFileWriter")
func testWritesAndLeavesNoTmp() throws { struct AtomicFileWriterTests {
@Test func writesAndLeavesNoTmp() throws {
let dir = try tempDir() let dir = try tempDir()
let url = dir.appendingPathComponent("state.json") let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url) try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}") #expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path)) #expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
} }
func testOverwritesExistingAtomically() throws { @Test func overwritesExistingAtomically() throws {
let dir = try tempDir() let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt") let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url) try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", to: url) try AtomicFileWriter.write("two-longer", to: url)
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer") #expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
} }
func testCreatesParentDirs() throws { @Test func createsParentDirs() throws {
let dir = try tempDir() let dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json") let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url) try AtomicFileWriter.write("{}", to: url)
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) #expect(FileManager.default.fileExists(atPath: url.path))
} }
} }
final class ArtefactProbeTests: XCTestCase { @Suite("ArtefactProbe")
func testVerifyProgression() throws { struct ArtefactProbeTests {
@Test func verifyProgression() throws {
let dir = try tempDir() let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir) var v = ArtefactProbe.verify(basename: "t", cwd: dir)
XCTAssertEqual(v, StageArtefacts()) #expect(v == StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1")) try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) v = ArtefactProbe.verify(basename: "t", cwd: dir)
XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete) #expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
try touch(dir.appendingPathComponent("t.ti2")) try touch(dir.appendingPathComponent("t.ti2"))
try touch(dir.appendingPathComponent("t.ti3")) try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) v = ArtefactProbe.verify(basename: "t", cwd: dir)
XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete) #expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
try touch(dir.appendingPathComponent("t.icc")) try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) v = ArtefactProbe.verify(basename: "t", cwd: dir)
XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc") #expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
} }
func testIcmWinsOverIcc() throws { @Test func icmWinsOverIcc() throws {
let dir = try tempDir() let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc")) try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm")) try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir) let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
XCTAssertEqual(profile?.pathExtension, "icm") #expect(profile?.pathExtension == "icm")
} }
func testEnumeratesPassesPagesAndCAL() throws { @Test func enumeratesPassesPagesAndCAL() throws {
let dir = try tempDir() let dir = try tempDir()
for name in [ for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif", "t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
@@ -112,15 +115,15 @@ final class ArtefactProbeTests: XCTestCase {
"t.ti3", "t_pass1.ti3", "t_pass2.ti3", "t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal", "t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
] { ] {
XCTAssertTrue(names.contains(expected), "missing \(expected)") #expect(names.contains(expected), "missing \(expected)")
} }
XCTAssertFalse(names.contains("other.ti1")) #expect(!names.contains("other.ti1"))
XCTAssertFalse(names.contains("t.txt")) #expect(!names.contains("t.txt"))
XCTAssertFalse(names.contains("CAL_other.ti1")) #expect(!names.contains("CAL_other.ti1"))
} }
func testEmptyDirReturnsEmpty() throws { @Test func emptyDirReturnsEmpty() throws {
let dir = try tempDir() let dir = try tempDir()
XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty) #expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
} }
} }
@@ -1,13 +1,15 @@
import XCTest import Testing
import SceneKit import SceneKit
import ICCeryCore import ICCeryCore
@testable import ICCery @testable import ICCery
/// ``GamutSceneGeometryBuilder`` edge-case tests. /// ``GamutSceneGeometryBuilder`` edge-case tests.
@Suite("Gamut scene geometry builder")
@MainActor @MainActor
final class GamutGeometryBuilderTests: XCTestCase { struct GamutGeometryBuilderTests {
func testDropsOutOfBoundsFaces() { @Test("Drops out-of-bounds faces from the element without crashing")
func dropsOutOfBoundsFaces() {
let white = GamutVertex( let white = GamutVertex(
lab: LabColor(l: 100, a: 0, b: 0), lab: LabColor(l: 100, a: 0, b: 0),
rgb: DisplayRGB(r: 1, g: 1, b: 1) rgb: DisplayRGB(r: 1, g: 1, b: 1)
@@ -26,6 +28,6 @@ final class GamutGeometryBuilderTests: XCTestCase {
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh) let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer") #expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer")
} }
} }
@@ -1,9 +1,10 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests. /// ``GamutMeshParser`` acceptance + edge-case tests.
final class GamutMeshParserTests: XCTestCase { @Suite("Gamut mesh parser")
struct GamutMeshParserTests {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL { private var bundledSRGBGamURL: URL {
@@ -12,14 +13,16 @@ final class GamutMeshParserTests: XCTestCase {
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
} }
func testParsesBundledSRGB() throws { @Test("Parses bundled sRGB.gam")
func parsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL) let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices") #expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces") #expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
} }
func testDiscardsVertexNo() throws { @Test("Discards VERTEX_NO and uses push-order indices")
func discardsVertexNo() throws {
let text = """ let text = """
GAMUT GAMUT
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
@@ -46,13 +49,14 @@ final class GamutMeshParserTests: XCTestCase {
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
XCTAssertEqual(mesh.vertices.count, 4) #expect(mesh.vertices.count == 4)
XCTAssertEqual(mesh.faces.count, 2) #expect(mesh.faces.count == 2)
XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30)) #expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30))
XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60)) #expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60))
} }
func testIgnoresComments() throws { @Test("Ignores comments and blank lines")
func ignoresComments() throws {
let text = """ let text = """
# Header comment # Header comment
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
@@ -77,11 +81,12 @@ final class GamutMeshParserTests: XCTestCase {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
XCTAssertEqual(mesh.vertices.count, 2) #expect(mesh.vertices.count == 2)
XCTAssertEqual(mesh.faces.count, 1) #expect(mesh.faces.count == 1)
} }
func testRemapsCoordinates() throws { @Test("Remaps coordinates to x=a*, y=L*, z=b*")
func remapsCoordinates() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -94,10 +99,11 @@ final class GamutMeshParserTests: XCTestCase {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80)) #expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
} }
func testComputesVertexColor() throws { @Test("Computes per-vertex sRGB colour")
func computesVertexColor() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -110,13 +116,14 @@ final class GamutMeshParserTests: XCTestCase {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
let white = try XCTUnwrap(mesh.vertices.first).rgb let white = try #require(mesh.vertices.first).rgb
XCTAssertTrue(white.r > 0.95) #expect(white.r > 0.95)
XCTAssertTrue(white.g > 0.95) #expect(white.g > 0.95)
XCTAssertTrue(white.b > 0.95) #expect(white.b > 0.95)
} }
func testDropsOutOfBoundsFaces() throws { @Test("Drops out-of-bounds face indices")
func dropsOutOfBoundsFaces() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -139,23 +146,21 @@ final class GamutMeshParserTests: XCTestCase {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
XCTAssertEqual(mesh.faces.count, 1) #expect(mesh.faces.count == 1)
} }
func testThrowsOnEmptyFile() { @Test("Throws on empty file")
XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in func throwsOnEmptyFile() {
guard case GamutMeshParseError.noDataBlock = error else { #expect(throws: GamutMeshParseError.noDataBlock) {
return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)") _ = try GamutMeshParser.parse(text: "")
}
} }
} }
func testThrowsWhenMissing() { @Test("Throws when file is missing")
func throwsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam") let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in #expect(throws: GamutMeshParseError.missingFile) {
guard case GamutMeshParseError.missingFile = error else { _ = try GamutMeshParser.parse(url: url)
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
}
} }
} }
} }
@@ -1,14 +1,16 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class IccgamutArgsTests: XCTestCase { @Suite("IccgamutArgs")
struct IccgamutArgsTests {
func testDensityNotDirectory() throws { @Test("Density is 10 and not a directory")
func densityNotDirectory() throws {
let config = IccgamutConfig( let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc") profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
) )
let args = try IccgamutArgs.build(config: config) let args = try IccgamutArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"]) #expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
} }
} }
+21 -14
View File
@@ -1,23 +1,26 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class JSONFileStoreTests: XCTestCase { @Suite("JSONFileStore")
struct JSONFileStoreTests {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json") .appendingPathComponent("json-store-\(UUID().uuidString).json")
} }
func testMissingFileDefaults() throws { @Test("Missing file returns default")
func missingFileDefaults() throws {
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
fileURL: tempURL(), fileURL: tempURL(),
corrupt: .throwCorrupt, corrupt: .throwCorrupt,
defaultValue: { .default } defaultValue: { .default }
) )
XCTAssertEqual(try store.load(), .default) #expect(try store.load() == .default)
} }
func testCorruptDefaults() throws { @Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
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)
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
@@ -25,12 +28,13 @@ final class JSONFileStoreTests: XCTestCase {
corrupt: .replaceWithDefault, corrupt: .replaceWithDefault,
defaultValue: { .default } defaultValue: { .default }
) )
XCTAssertEqual(try store.load(), .default) #expect(try store.load() == .default)
let kept = try String(contentsOf: url, encoding: .utf8) let kept = try String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(kept, "{ not json") #expect(kept == "{ not json")
} }
func testCorruptThrows() throws { @Test("Corrupt file with throwCorrupt throws and leaves bytes")
func corruptThrows() 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)
let store = JSONFileStore<[Int]>( let store = JSONFileStore<[Int]>(
@@ -38,12 +42,15 @@ final class JSONFileStoreTests: XCTestCase {
corrupt: .throwCorrupt, corrupt: .throwCorrupt,
defaultValue: { [] } defaultValue: { [] }
) )
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) } #expect(throws: DecodingError.self) {
_ = try store.load()
}
let kept = try String(contentsOf: url, encoding: .utf8) let kept = try String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(kept, "not json") #expect(kept == "not json")
} }
func testPrettySorted() throws { @Test("Pretty sorted keys")
func prettySorted() throws {
let url = tempURL() let url = tempURL()
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
fileURL: url, fileURL: url,
@@ -52,8 +59,8 @@ final class JSONFileStoreTests: XCTestCase {
) )
try store.save(.default) try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8) let text = try String(contentsOf: url, encoding: .utf8)
XCTAssertTrue(text.contains("\n")) #expect(text.contains("\n"))
XCTAssertTrue(text.contains("\"delta_e_good_max\"")) #expect(text.contains("\"delta_e_good_max\""))
// Lexical key sorting: ascending order of top-level keys. // Lexical key sorting: ascending order of top-level keys.
let keys = [ let keys = [
"ask_before_overwrite_profile", "ask_before_overwrite_profile",
@@ -68,7 +75,7 @@ final class JSONFileStoreTests: XCTestCase {
var lastIndex = text.startIndex var lastIndex = text.startIndex
for key in keys { for key in keys {
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else { guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
XCTFail("missing or out-of-order key \(key)") Issue.record("missing or out-of-order key \(key)")
return return
} }
lastIndex = range.upperBound lastIndex = range.upperBound
+57 -44
View File
@@ -1,11 +1,12 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`). /// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`).
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover /// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
/// flag order, captured-option precedence, and sanitisation. /// flag order, captured-option precedence, and sanitisation.
final class LpArgsTests: XCTestCase { @Suite("LpArgs")
struct LpArgsTests {
private let tiff = "/tmp/work/target_001.tif" private let tiff = "/tmp/work/target_001.tif"
private let queue = "EPSON_XP_55_Series" private let queue = "EPSON_XP_55_Series"
@@ -19,100 +20,112 @@ final class LpArgsTests: XCTestCase {
options: options, optionKeys: optionKeys) options: options, optionKeys: optionKeys)
} }
func testHeader() throws { @Test("Header: -d queue -t title, both AP_* first, TIFF last")
func header() throws {
let argv = try build() let argv = try build()
XCTAssertEqual(Array(argv[0...1]), ["-d", queue]) #expect(Array(argv[0...1]) == ["-d", queue])
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"]) #expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) #expect(Array(argv[4...5])
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) == ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(argv.last, tiff) #expect(Array(argv[6...7])
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" }) == ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
} }
func testNeverRaw() throws { @Test("Never emits -o raw; captured raw= is dropped")
func neverRaw() throws {
let argv = try build(options: PrintOptions( let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo")) cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" { for (i, arg) in argv.enumerated() where arg == "-o" {
XCTAssertNotEqual(argv[i + 1], "raw") #expect(argv[i + 1] != "raw")
XCTAssertNotEqual(argv[i + 1], "raw=true") #expect(argv[i + 1] != "raw=true")
} }
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") }) #expect(!argv.contains { $0.hasPrefix("raw=") })
XCTAssertTrue(argv.contains("MediaType=Photo")) #expect(argv.contains("MediaType=Photo"))
} }
func testCapturedReplay() throws { @Test("Captured options replayed after AP_* headers")
func capturedReplay() throws {
let argv = try build(options: PrintOptions( let argv = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear MediaType=Photo")) cupsOptions: "InputSlot=Rear MediaType=Photo"))
let rear = argv.firstIndex(of: "InputSlot=Rear")! let rear = argv.firstIndex(of: "InputSlot=Rear")!
let apFirst = argv.firstIndex(of: let apFirst = argv.firstIndex(of:
"AP_ColorMatchingMode=AP_ApplicationColorMatching")! "AP_ColorMatchingMode=AP_ApplicationColorMatching")!
XCTAssertTrue(rear > apFirst) #expect(rear > apFirst)
} }
func testCapturedWinsMedia() throws { @Test("Captured wins: media key present → derived media skipped")
func capturedWinsMedia() throws {
let argv = try build( let argv = try build(
options: PrintOptions( options: PrintOptions(
mediaType: "Plain", mediaType: "Plain",
cupsOptions: "MediaType=Glossy"), cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"]) optionKeys: ["MediaType"])
XCTAssertTrue(argv.contains("MediaType=Glossy")) #expect(argv.contains("MediaType=Glossy"))
XCTAssertFalse(argv.contains("MediaType=Plain")) #expect(!argv.contains("MediaType=Plain"))
} }
func testMediaDerived() throws { @Test("Media emitted via detected key when not captured")
func mediaDerived() throws {
let argv = try build( let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"), options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"]) optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order. // CNIJMediaType wins over MediaType in detection order.
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss")) #expect(argv.contains("CNIJMediaType=SemiGloss"))
XCTAssertFalse(argv.contains("MediaType=SemiGloss")) #expect(!argv.contains("MediaType=SemiGloss"))
} }
func testBypassRules() throws { @Test("Driver bypass emitted when absent, skipped when captured")
func bypassRules() throws {
let withBypass = try build( let withBypass = try build(
optionKeys: ["EPIJ_CMat"]) optionKeys: ["EPIJ_CMat"])
XCTAssertTrue(withBypass.contains("EPIJ_CMat=3")) #expect(withBypass.contains("EPIJ_CMat=3"))
let captured = try build( let captured = try build(
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
optionKeys: ["EPIJ_CMat"]) optionKeys: ["EPIJ_CMat"])
// Captured value kept, detection not re-applied. // Captured value kept, detection not re-applied.
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"]) #expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
== ["EPIJ_CMat=1"])
} }
func testOrientation() throws { @Test("Orientation: portrait=3 landscape=4; captured wins")
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait")) func orientation() throws {
#expect(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3")) .contains("orientation-requested=3"))
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape")) #expect(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4")) .contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions( let capturedOrients = try build(options: PrintOptions(
orientation: "landscape", orientation: "landscape",
cupsOptions: "orientation-requested=5")) cupsOptions: "orientation-requested=5"))
XCTAssertFalse(capturedOrients.contains("orientation-requested=4")) #expect(!capturedOrients.contains("orientation-requested=4"))
XCTAssertTrue(capturedOrients.contains("orientation-requested=5")) #expect(capturedOrients.contains("orientation-requested=5"))
} }
func testPageSize() throws { @Test("PageSize emitted unless captured")
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4")) func pageSize() throws {
#expect(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4")) .contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions( let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter")) paperSize: "A4", cupsOptions: "PageSize=Letter"))
XCTAssertFalse(capturedSize.contains("PageSize=A4")) #expect(!capturedSize.contains("PageSize=A4"))
XCTAssertTrue(capturedSize.contains("PageSize=Letter")) #expect(capturedSize.contains("PageSize=Letter"))
} }
func testSanitise() throws { @Test("Sanitise rejects `;`, newline, and shell metachars")
XCTAssertThrowsError(try build(options: PrintOptions( func sanitise() throws {
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in #expect(throws: LpArgsError.self) {
XCTAssertTrue(error is LpArgsError) _ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))
} }
XCTAssertThrowsError(try build(options: PrintOptions( #expect(throws: LpArgsError.self) {
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in _ = try build(options: PrintOptions(
XCTAssertTrue(error is LpArgsError) cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
} }
XCTAssertThrowsError(try build(options: PrintOptions( #expect(throws: LpArgsError.self) {
cupsOptions: "InputSlot=$(whoami)"))) { error in _ = try build(options: PrintOptions(
XCTAssertTrue(error is LpArgsError) cupsOptions: "InputSlot=$(whoami)"))
} }
} }
} }
+113 -123
View File
@@ -1,10 +1,12 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class InstrumentParserTests: XCTestCase { @Suite("InstrumentParser")
struct InstrumentParserTests {
func testJson() throws { @Test("Parses pretty-printed instlist JSON")
func json() throws {
let json = """ let json = """
{ {
"event": "instruments", "event": "instruments",
@@ -16,118 +18,134 @@ final class InstrumentParserTests: XCTestCase {
} }
""" """
let devices = try InstrumentParser.parse(json) let devices = try InstrumentParser.parse(json)
XCTAssertEqual(devices.count, 3) #expect(devices.count == 3)
XCTAssertEqual(devices[0].port, 1) #expect(devices[0].port == 1)
XCTAssertEqual(devices[0].name, "X-Rite i1Pro") #expect(devices[0].name == "X-Rite i1Pro")
XCTAssertEqual(devices[2].port, 3) #expect(devices[2].port == 3)
} }
func testRegexFallback() throws { @Test("Falls back to regex for legacy instlist text")
func regexFallback() throws {
let text = """ let text = """
1: 'X-Rite i1Pro' on usb 1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile' 2: 'ColorMunki Smile'
""" + "\n" """ + "\n"
let devices = try InstrumentParser.parse(text) let devices = try InstrumentParser.parse(text)
XCTAssertEqual(devices.count, 2) #expect(devices.count == 2)
XCTAssertEqual(devices[0].port, 1) #expect(devices[0].port == 1)
XCTAssertEqual(devices[1].name, "ColorMunki Smile") #expect(devices[1].name == "ColorMunki Smile")
} }
func testEmpty() throws { @Test("Empty output returns no devices")
XCTAssertTrue(try InstrumentParser.parse("").isEmpty) func empty() throws {
#expect(try InstrumentParser.parse("").isEmpty)
} }
} }
final class ChartreadArgsTests: XCTestCase { @Suite("ChartreadArgs")
struct ChartreadArgsTests {
func testBaseline() throws { @Test("Baseline argv and port 1 omits -c")
func baseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1) let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-u", "target"]) #expect(args == ["-v", "-u", "target"])
} }
func testPortArgument() throws { @Test("Port > 1 emits -c")
func portArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3) let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"]) #expect(args == ["-v", "-u", "-c", "3", "target"])
} }
func testLeds() throws { @Test("LEDs emit -Y l")
func leds() throws {
let config = ChartreadConfig( let config = ChartreadConfig(
basename: "target", basename: "target",
selectedPort: 2, selectedPort: 2,
enableLEDs: true enableLEDs: true
) )
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
XCTAssertTrue(args.contains("-Y")) #expect(args.contains("-Y"))
XCTAssertTrue(args.contains("l")) #expect(args.contains("l"))
} }
func testAutoPort() throws { @Test("Auto omits -c")
func autoPort() throws {
let config = ChartreadConfig(basename: "target") let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
XCTAssertFalse(args.contains("-c")) #expect(!args.contains("-c"))
} }
} }
final class ChartreadClassifierTests: XCTestCase { @Suite("ChartreadClassifier")
struct ChartreadClassifierTests {
func testCalibration() { @Test("Calibration prompt")
func calibration() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.", line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle previousState: .idle
) )
XCTAssertEqual(r.state, .calibrating) #expect(r.state == .calibrating)
} }
func testAwaitingStrip() { @Test("Strip awaiting")
func awaitingStrip() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A", line: "Hit [Space] to read strip A",
previousState: .calibrating previousState: .calibrating
) )
XCTAssertEqual(r.state, .awaitingStrip) #expect(r.state == .awaitingStrip)
} }
func testDone() { @Test("Done prompt")
func done() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "'d' if/when done", line: "'d' if/when done",
previousState: .awaitingStrip previousState: .awaitingStrip
) )
XCTAssertEqual(r.state, .allStripsRead) #expect(r.state == .allStripsRead)
} }
func testPlaceSheet() { @Test("XY place sheet")
func placeSheet() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table", line: "Please place sheet 1 of 2 on the table",
previousState: .idle previousState: .idle
) )
XCTAssertEqual(r.state, .tablePlaceSheet) #expect(r.state == .tablePlaceSheet)
XCTAssertEqual(r.sheetNumber, 1) #expect(r.sheetNumber == 1)
XCTAssertEqual(r.sheetTotal, 2) #expect(r.sheetTotal == 2)
} }
func testLocatePatch() { @Test("XY locate patch")
func locatePatch() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,", line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet previousState: .tablePlaceSheet
) )
XCTAssertEqual(r.state, .tableAlign) #expect(r.state == .tableAlign)
XCTAssertEqual(r.alignmentPatch, "A1") #expect(r.alignmentPatch == "A1")
} }
func testRemoveNotice() { @Test("Remove sheet notice preserves state")
func removeNotice() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table", line: "Please remove last sheet from table",
previousState: .tablePlaceSheet previousState: .tablePlaceSheet
) )
XCTAssertEqual(r.state, .tablePlaceSheet) #expect(r.state == .tablePlaceSheet)
XCTAssertEqual(r.isRemoveSheetNotice, true) #expect(r.isRemoveSheetNotice == true)
} }
} }
final class ChartreadRowTests: XCTestCase { @Suite("ChartreadRow")
struct ChartreadRowTests {
func testDecode() throws { @Test("Decodes row JSON")
func decode() throws {
let json = """ let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [ "patch_count": 1, "patches": [
@@ -137,93 +155,59 @@ final class ChartreadRowTests: XCTestCase {
]} ]}
""" """
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
XCTAssertEqual(row.rowId, "A") #expect(row.rowId == "A")
XCTAssertEqual(row.patchCount, 1) #expect(row.patchCount == 1)
XCTAssertEqual(row.patches[0].measured.lab?.l, 51) #expect(row.patches[0].measured.lab?.l == 51)
}
func testDecodeXYZAndLab() throws {
let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [
{"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40],
"measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}}
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured
XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
}
func testXyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5])
}
}
func testLabWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
XCTAssertEqual(value as? [Double], [50, -1, 2])
}
}
func testPatchColorKeys() throws {
let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any]
XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
XCTAssertNil(object?["spectral"])
} }
} }
final class ColourMathTests: XCTestCase { @Suite("ColourMath")
struct ColourMathTests {
func testWhiteLab() { @Test("White XYZ to Lab")
func whiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188) let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white) let lab = LabColorMath.xyzToLab(white)
XCTAssertTrue(abs(lab.l - 100) < 0.5) #expect(abs(lab.l - 100) < 0.5)
XCTAssertTrue(abs(lab.a) < 0.5) #expect(abs(lab.a) < 0.5)
XCTAssertTrue(abs(lab.b) < 0.5) #expect(abs(lab.b) < 0.5)
} }
func testLabToSRGB() { @Test("Lab to sRGB roundtrip is clamped")
func labToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70) let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red) let rgb = LabColorMath.labToSRGB(red)
XCTAssertTrue(rgb.r > 0.8) #expect(rgb.r > 0.8)
XCTAssertTrue(rgb.g < 0.2) #expect(rgb.g < 0.2)
XCTAssertTrue(rgb.b < 0.2) #expect(rgb.b < 0.2)
} }
func testPadWhite() { @Test("Pad white returns DisplayRGB")
func padWhite() {
let white = LabColor(l: 95, a: 0, b: 0) let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white) let rgb = LabColorMath.labToSRGB(white)
XCTAssertTrue(rgb.r > 0.9) #expect(rgb.r > 0.9)
XCTAssertTrue(rgb.g > 0.9) #expect(rgb.g > 0.9)
XCTAssertTrue(rgb.b > 0.9) #expect(rgb.b > 0.9)
} }
func testCiede2000() { @Test("Standard CIEDE2000 vector (Sharma)")
func ciede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814) let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485) let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001) #expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
} }
func testClassify() { @Test("Classification respects thresholds")
XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good) func classify() {
XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning) #expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad) #expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
} }
} }
final class MeasurementArtefactTests: XCTestCase { @Suite("MeasurementArtefacts")
struct MeasurementArtefactTests {
private func makeCwd() throws -> URL { private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
@@ -232,7 +216,8 @@ final class MeasurementArtefactTests: XCTestCase {
return url return url
} }
func testDiscovery() throws { @Test("Discovers passes in order")
func discovery() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } defer { try? FileManager.default.removeItem(at: cwd) }
@@ -241,10 +226,11 @@ final class MeasurementArtefactTests: XCTestCase {
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8) try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd) let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"]) #expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
} }
func testSnapshotPromote() throws { @Test("Snapshot and promote are atomic")
func snapshotPromote() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } defer { try? FileManager.default.removeItem(at: cwd) }
@@ -252,15 +238,16 @@ final class MeasurementArtefactTests: XCTestCase {
try "canonical".write(to: canonical, atomically: true, encoding: .utf8) try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3") #expect(pass.lastPathComponent == "target_pass1.ti3")
XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path)) #expect(!FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd) let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
XCTAssertEqual(promoted.lastPathComponent, "target.ti3") #expect(promoted.lastPathComponent == "target.ti3")
XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path)) #expect(FileManager.default.fileExists(atPath: promoted.path))
} }
func testCollision() throws { @Test("Pass collisions handled")
func collision() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } defer { try? FileManager.default.removeItem(at: cwd) }
@@ -270,25 +257,28 @@ final class MeasurementArtefactTests: XCTestCase {
try "v2".write(to: canonical, atomically: true, encoding: .utf8) try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3") #expect(pass2.lastPathComponent == "target_pass2.ti3")
} }
} }
final class AverageArgsTests: XCTestCase { @Suite("AverageArgs")
struct AverageArgsTests {
func testPassCount() { @Test("Requires at least two pass files")
func passCount() {
let cwd = URL(fileURLWithPath: "/tmp") let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
basename: "target", basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")] passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
) )
XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in #expect(throws: AverageArgError.self) {
XCTAssertTrue(error is AverageArgError) _ = try AverageArgs.build(config: config)
} }
} }
func testOrdering() throws { @Test("Output is last and inputs are relative")
func ordering() throws {
let cwd = URL(fileURLWithPath: "/tmp") let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
@@ -299,8 +289,8 @@ final class AverageArgsTests: XCTestCase {
] ]
) )
let args = try AverageArgs.build(config: config) let args = try AverageArgs.build(config: config)
XCTAssertEqual(args.first, "-v") #expect(args.first == "-v")
XCTAssertEqual(args.last, "target.ti3") #expect(args.last == "target.ti3")
XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"]) #expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
} }
} }
+186 -163
View File
@@ -1,98 +1,116 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
final class ProfilingPresetTests: XCTestCase { @Suite("ProfilingPreset")
struct ProfilingPresetTests {
func testRoundTrip() throws { @Test("snake_case keys round-trip through Codable")
func roundTrip() throws {
var p = PresetCatalog.highQualityCMYK var p = PresetCatalog.highQualityCMYK
p.colprofInputViewingCond = "D50_2" p.colprofInputViewingCond = "D50_2"
let data = try JSONEncoder().encode(p) let data = try JSONEncoder().encode(p)
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
XCTAssertEqual(decoded, p) #expect(decoded == p)
// Spot-check the wire format. // Spot-check the wire format.
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
XCTAssertEqual(obj["colour_space"] as? String, "cmyk") #expect(obj["colour_space"] as? String == "cmyk")
XCTAssertEqual(obj["patch_count"] as? Int, 1500) #expect(obj["patch_count"] as? Int == 1500)
XCTAssertEqual(obj["total_ink_limit"] as? Int, 320) #expect(obj["total_ink_limit"] as? Int == 320)
XCTAssertEqual(obj["bit_depth"] as? Int, 16) #expect(obj["bit_depth"] as? Int == 16)
XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2") #expect(obj["colprof_input_viewing_cond"] as? String == "D50_2")
} }
func testSchemaTolerance() throws { @Test("Unknown keys ignored; missing required field fails")
func schemaTolerance() throws {
let json = """ let json = """
{"id":"x","name":"N","colour_space":"rgb","patch_count":10, {"id":"x","name":"N","colour_space":"rgb","patch_count":10,
"white_patches":1,"black_patches":1,"instrument":"i1", "white_patches":1,"black_patches":1,"instrument":"i1",
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42} "page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
""".data(using: .utf8)! """.data(using: .utf8)!
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json) let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
XCTAssertEqual(ok.id, "x") #expect(ok.id == "x")
let missing = """ let missing = """
{"id":"x","name":"N","colour_space":"rgb"} {"id":"x","name":"N","colour_space":"rgb"}
""".data(using: .utf8)! """.data(using: .utf8)!
XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) } #expect(throws: DecodingError.self) {
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
}
} }
func testValidation() { @Test("Validation rejects bad colour space / dpi / bit depth")
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } func validation() {
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } #expect(throws: ProfilingPreset.ValidationError.self) {
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } }
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()
}
} }
} }
final class PresetCatalogTests: XCTestCase { @Suite("PresetCatalog")
struct PresetCatalogTests {
func testBuiltIns() { @Test("Four built-ins with the documented values")
XCTAssertEqual(PresetCatalog.builtIns.count, 4) func builtIns() {
#expect(PresetCatalog.builtIns.count == 4)
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) }) let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
let std = byID["preset-std-rgb"]! let std = byID["preset-std-rgb"]!
XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800 #expect(std.colourSpace == "rgb" && std.patchCount == 800
&& std.pageSize == "A4" && std.bitDepth == 8 && std.pageSize == "A4" && std.bitDepth == 8
&& std.dpi == 300 && std.colprofQuality == "m" && std.dpi == 300 && std.colprofQuality == "m"
&& std.whitePatches == 4 && std.blackPatches == 4) && std.whitePatches == 4 && std.blackPatches == 4)
let hq = byID["preset-hq-cmyk"]! let hq = byID["preset-hq-cmyk"]!
XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500 #expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
&& hq.pageSize == "A3" && hq.bitDepth == 16 && hq.pageSize == "A3" && hq.bitDepth == 16
&& hq.dpi == 300 && hq.colprofQuality == "h" && hq.dpi == 300 && hq.colprofQuality == "h"
&& hq.totalInkLimit == 320 && hq.blackPatches == 8) && hq.totalInkLimit == 320 && hq.blackPatches == 8)
let draft = byID["preset-draft-rgb"]! let draft = byID["preset-draft-rgb"]!
XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400 #expect(draft.colourSpace == "rgb" && draft.patchCount == 400
&& draft.pageSize == "A4" && draft.bitDepth == 8 && draft.pageSize == "A4" && draft.bitDepth == 8
&& draft.dpi == 150 && draft.colprofQuality == "l") && draft.dpi == 150 && draft.colprofQuality == "l")
let ultra = byID["preset-ultra-rgb"]! let ultra = byID["preset-ultra-rgb"]!
XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500 #expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
&& ultra.pageSize == "A3" && ultra.bitDepth == 16 && ultra.pageSize == "A3" && ultra.bitDepth == 16
&& ultra.dpi == 300 && ultra.colprofQuality == "u" && ultra.dpi == 300 && ultra.colprofQuality == "u"
&& ultra.ofpsHighQuality == true && ultra.ofpsHighQuality == true
&& ultra.whitePatches == 6 && ultra.blackPatches == 6) && ultra.whitePatches == 6 && ultra.blackPatches == 6)
for p in PresetCatalog.builtIns { for p in PresetCatalog.builtIns {
XCTAssertEqual(p.instrument, "i1") #expect(p.instrument == "i1")
XCTAssertEqual(p.colprofFwa, "D50") #expect(p.colprofFwa == "D50")
XCTAssertEqual(p.randomSeed, 1) #expect(p.randomSeed == 1)
XCTAssertEqual(p.noRandomize, false) #expect(p.noRandomize == false)
XCTAssertEqual(p.colprofAlgorithm, "l") #expect(p.colprofAlgorithm == "l")
} }
} }
func testOverlay() { @Test("Custom presets overlay by id; built-ins are not deletable")
func overlay() {
let custom = ProfilingPreset( let custom = ProfilingPreset(
id: "preset-std-rgb", name: "Shadowed", patchCount: 42) id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
let all = PresetCatalog.all(custom: [custom]) let all = PresetCatalog.all(custom: [custom])
XCTAssertEqual(all.count, 4) #expect(all.count == 4)
XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42) #expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42)
XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb")) #expect(PresetCatalog.isBuiltIn("preset-std-rgb"))
XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1")) #expect(!PresetCatalog.isBuiltIn("custom-1"))
} }
} }
final class PresetStoreTests: XCTestCase { @Suite("PresetStore")
struct PresetStoreTests {
private func tempSettingsURL() throws -> URL { private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -101,52 +119,58 @@ final class PresetStoreTests: XCTestCase {
return dir.appendingPathComponent("settings.json") return dir.appendingPathComponent("settings.json")
} }
func testCrud() throws { @Test("CRUD + export/import round-trip")
func crud() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150) var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
try store.saveCustom(p) try store.saveCustom(p)
XCTAssertEqual(store.customs().count, 1) #expect(store.customs().count == 1)
XCTAssertEqual(store.all().count, 5) #expect(store.all().count == 5)
p.name = "Renamed" p.name = "Renamed"
try store.saveCustom(p) try store.saveCustom(p)
XCTAssertEqual(store.customs().count, 1) #expect(store.customs().count == 1)
XCTAssertEqual(store.customs()[0].name, "Renamed") #expect(store.customs()[0].name == "Renamed")
let data = try store.export(p) let data = try store.export(p)
let imported = try store.import(data) let imported = try store.import(data)
XCTAssertEqual(imported.name, "Renamed") #expect(imported.name == "Renamed")
XCTAssertEqual(imported.dpi, 150) #expect(imported.dpi == 150)
XCTAssertTrue(try store.deleteCustom(id: "custom-x")) #expect(try store.deleteCustom(id: "custom-x"))
XCTAssertTrue(store.customs().isEmpty) #expect(store.customs().isEmpty)
XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb")) #expect(try !store.deleteCustom(id: "preset-std-rgb"))
} }
func testImportBuiltinCollision() throws { @Test("Import rewrites a built-in id to a fresh custom id")
func importBuiltinCollision() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.standardRGB) let data = try store.export(PresetCatalog.standardRGB)
let imported = try store.import(data) let imported = try store.import(data)
XCTAssertTrue(imported.id.hasPrefix("custom-")) #expect(imported.id.hasPrefix("custom-"))
XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id)) #expect(!PresetCatalog.isBuiltIn(imported.id))
} }
func testBuiltInImmutable() throws { @Test("Built-ins are immutable through saveCustom")
func builtInImmutable() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var shadowed = PresetCatalog.standardRGB var shadowed = PresetCatalog.standardRGB
shadowed.name = "Hacked" shadowed.name = "Hacked"
XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) } #expect(throws: PresetStore.PresetStoreError.self) {
try store.saveCustom(shadowed)
}
} }
} }
final class PresetMigrationTests: XCTestCase { @Suite("AppSettings preset migration")
struct PresetMigrationTests {
private func tempSettingsURL() throws -> URL { private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -155,7 +179,8 @@ final class PresetMigrationTests: XCTestCase {
return dir.appendingPathComponent("settings.json") return dir.appendingPathComponent("settings.json")
} }
func testLegacyMigration() throws { @Test("Legacy M1 custom_presets migrate to typed schema")
func legacyMigration() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let legacy = """ let legacy = """
@@ -169,19 +194,20 @@ final class PresetMigrationTests: XCTestCase {
try legacy.write(to: url) try legacy.write(to: url)
let settings = SettingsStore(fileURL: url).load() let settings = SettingsStore(fileURL: url).load()
XCTAssertEqual(settings.customPresets.count, 1) #expect(settings.customPresets.count == 1)
let p = settings.customPresets[0] let p = settings.customPresets[0]
XCTAssertEqual(p.name, "Old One") #expect(p.name == "Old One")
XCTAssertTrue(p.id.hasPrefix("custom-0-")) #expect(p.id.hasPrefix("custom-0-"))
XCTAssertEqual(p.colourSpace, "cmyk") #expect(p.colourSpace == "cmyk")
XCTAssertEqual(p.patchCount, 900) #expect(p.patchCount == 900)
XCTAssertEqual(p.dpi, 150) #expect(p.dpi == 150)
XCTAssertEqual(p.bitDepth, 16) #expect(p.bitDepth == 16)
XCTAssertEqual(p.instrument, "p3") #expect(p.instrument == "p3")
XCTAssertEqual(p.pageSize, "A3") #expect(p.pageSize == "A3")
} }
func testTypedRoundTrip() throws { @Test("Typed presets load and re-save as the typed schema")
func typedRoundTrip() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
@@ -189,45 +215,50 @@ final class PresetMigrationTests: XCTestCase {
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)] s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
try store.save(s) try store.save(s)
let loaded = store.load() let loaded = store.load()
XCTAssertEqual(loaded.customPresets.first?.patchCount, 700) #expect(loaded.customPresets.first?.patchCount == 700)
} }
func testDraftDPI() throws { @Test("Draft preset dpi=150 survives Codable + settings round-trip")
func draftDPI() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.draftRGB) let data = try store.export(PresetCatalog.draftRGB)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
XCTAssertEqual(obj["dpi"] as? Int, 150) #expect(obj["dpi"] as? Int == 150)
let back = try store.import(data) let back = try store.import(data)
XCTAssertEqual(back.dpi, 150) #expect(back.dpi == 150)
} }
} }
final class PresetMappingTests: XCTestCase { @Suite("Preset mapping")
func testDraftDpi() { struct PresetMappingTests {
@Test("Draft 150 DPI maps into PrinttargConfig")
func draftDpi() {
let cfg = PrinttargConfig( let cfg = PrinttargConfig(
preset: PresetCatalog.draftRGB, preset: PresetCatalog.draftRGB,
basename: "t", basename: "t",
workingDirectory: nil, workingDirectory: nil,
calibrationFile: nil calibrationFile: nil
) )
XCTAssertEqual(cfg.dpi, 150) #expect(cfg.dpi == 150)
XCTAssertEqual(cfg.layoutOrder, .deterministic) #expect(cfg.layoutOrder == .deterministic)
} }
func testOptionalNil() { @Test("Nil optional targen fields stay nil")
func optionalNil() {
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800) let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertNil(cfg.greySteps) #expect(cfg.greySteps == nil)
XCTAssertNil(cfg.singleChannelSteps) #expect(cfg.singleChannelSteps == nil)
XCTAssertNil(cfg.neutralSteps) #expect(cfg.neutralSteps == nil)
XCTAssertNil(cfg.totalInkLimit) #expect(cfg.totalInkLimit == nil)
XCTAssertNil(cfg.darkEmphasis) #expect(cfg.darkEmphasis == nil)
XCTAssertNil(cfg.devicePower) #expect(cfg.devicePower == nil)
} }
func testRoundTripConfigs() { @Test("Custom page and FWA survive a config round-trip")
func roundTripConfigs() {
var preset = PresetCatalog.highQualityCMYK var preset = PresetCatalog.highQualityCMYK
preset.pageSize = "210x297" preset.pageSize = "210x297"
preset.colprofFwa = "D50" preset.colprofFwa = "D50"
@@ -237,9 +268,9 @@ final class PresetMappingTests: XCTestCase {
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
) )
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil) let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
XCTAssertEqual(printtarg.pageSize, .custom) #expect(printtarg.pageSize == .custom)
XCTAssertEqual(printtarg.customPageWidth, 210) #expect(printtarg.customPageWidth == 210)
XCTAssertEqual(colprof.fwa, "D50") #expect(colprof.fwa == "D50")
let back = ProfilingPreset( let back = ProfilingPreset(
id: preset.id, id: preset.id,
name: preset.name, name: preset.name,
@@ -250,14 +281,15 @@ final class PresetMappingTests: XCTestCase {
calibrationFile: preset.calibrationFile, calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration applyCalibration: preset.applyCalibration
) )
XCTAssertEqual(back.dpi, preset.dpi) #expect(back.dpi == preset.dpi)
XCTAssertEqual(back.colourSpace, "cmyk") #expect(back.colourSpace == "cmyk")
XCTAssertEqual(back.pageSize, "210x297") #expect(back.pageSize == "210x297")
XCTAssertEqual(back.colprofFwa, "D50") #expect(back.colprofFwa == "D50")
XCTAssertNil(back.greySteps) #expect(back.greySteps == nil)
} }
func testFullRoundTrip() { @Test("Full preset round-trips through all three configs with every field asserted")
func fullRoundTrip() {
let preset = ProfilingPreset( let preset = ProfilingPreset(
id: "custom-full", id: "custom-full",
name: "Full", name: "Full",
@@ -296,21 +328,21 @@ final class PresetMappingTests: XCTestCase {
) )
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil) let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
XCTAssertEqual(targen.colourSpace, .cmyk) #expect(targen.colourSpace == .cmyk)
XCTAssertEqual(targen.patchCount, 1500) #expect(targen.patchCount == 1500)
XCTAssertEqual(targen.whitePatches, 6) #expect(targen.whitePatches == 6)
XCTAssertEqual(targen.blackPatches, 8) #expect(targen.blackPatches == 8)
XCTAssertEqual(targen.greySteps, 9) #expect(targen.greySteps == 9)
XCTAssertEqual(targen.singleChannelSteps, 7) #expect(targen.singleChannelSteps == 7)
XCTAssertEqual(targen.neutralSteps, 4) #expect(targen.neutralSteps == 4)
XCTAssertEqual(targen.neutralConcentration, 0.7) #expect(targen.neutralConcentration == 0.7)
XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm") #expect(targen.preconditioningProfile == "/tmp/pre.icm")
XCTAssertEqual(targen.ofpsHighQuality, true) #expect(targen.ofpsHighQuality == true)
XCTAssertEqual(targen.ofpsAdaptation, 0.2) #expect(targen.ofpsAdaptation == 0.2)
XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom) #expect(targen.fullSpreadAlgorithm == .uniformRandom)
XCTAssertEqual(targen.totalInkLimit, 280) #expect(targen.totalInkLimit == 280)
XCTAssertEqual(targen.darkEmphasis, 1.3) #expect(targen.darkEmphasis == 1.3)
XCTAssertEqual(targen.devicePower, 1.2) #expect(targen.devicePower == 1.2)
let printtarg = PrinttargConfig( let printtarg = PrinttargConfig(
preset: preset, preset: preset,
@@ -318,25 +350,25 @@ final class PresetMappingTests: XCTestCase {
workingDirectory: nil, workingDirectory: nil,
calibrationFile: preset.calibrationFile calibrationFile: preset.calibrationFile
) )
XCTAssertEqual(printtarg.instrument, .p3) #expect(printtarg.instrument == .p3)
XCTAssertEqual(printtarg.pageSize, .custom) #expect(printtarg.pageSize == .custom)
XCTAssertEqual(printtarg.customPageWidth, 250) #expect(printtarg.customPageWidth == 250)
XCTAssertEqual(printtarg.customPageHeight, 300) #expect(printtarg.customPageHeight == 300)
XCTAssertEqual(printtarg.bitDepth, .sixteen) #expect(printtarg.bitDepth == .sixteen)
XCTAssertEqual(printtarg.dpi, 360) #expect(printtarg.dpi == 360)
XCTAssertEqual(printtarg.layoutOrder, .customSeed) #expect(printtarg.layoutOrder == .customSeed)
XCTAssertEqual(printtarg.customSeed, 42) #expect(printtarg.customSeed == 42)
XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal") #expect(printtarg.calibrationFile == "/tmp/a.cal")
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil) let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
XCTAssertEqual(colprof.algorithm, "x") #expect(colprof.algorithm == "x")
XCTAssertEqual(colprof.quality, "u") #expect(colprof.quality == "u")
XCTAssertEqual(colprof.intent, "p") #expect(colprof.intent == "p")
XCTAssertEqual(colprof.fwa, "D65") #expect(colprof.fwa == "D65")
XCTAssertEqual(colprof.illuminant, "D65") #expect(colprof.illuminant == "D65")
XCTAssertEqual(colprof.observer, "1931_2") #expect(colprof.observer == "1931_2")
XCTAssertEqual(colprof.inputViewingCond, "D50_2") #expect(colprof.inputViewingCond == "D50_2")
XCTAssertEqual(colprof.outputViewingCond, "D65_2") #expect(colprof.outputViewingCond == "D65_2")
let back = ProfilingPreset( let back = ProfilingPreset(
id: preset.id, id: preset.id,
@@ -348,12 +380,11 @@ final class PresetMappingTests: XCTestCase {
calibrationFile: preset.calibrationFile, calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration applyCalibration: preset.applyCalibration
) )
XCTAssertEqual(back, preset) #expect(back == preset)
} }
func testFullSpreadAlgorithms() { @Test("Every full-spread algorithm round-trips", arguments: [
let cases: [(String, FullSpreadAlgorithm)] = [ ("ofps", FullSpreadAlgorithm.ofps),
("ofps", .ofps),
("t", .target), ("t", .target),
("r", .random), ("r", .random),
("R", .uniformRandom), ("R", .uniformRandom),
@@ -361,16 +392,16 @@ final class PresetMappingTests: XCTestCase {
("Q", .uniformQuasiRandom), ("Q", .uniformQuasiRandom),
("i", .invertedQuasiRandom), ("i", .invertedQuasiRandom),
("I", .invertedUniformQuasiRandom) ("I", .invertedUniformQuasiRandom)
] ])
for (value, expected) in cases { func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.fullSpreadAlgorithm = value preset.fullSpreadAlgorithm = value
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
if expected == .ofps { if expected == .ofps {
// ofps is the default no flag emitted, stored value is nil. // ofps is the default no flag emitted, stored value is nil.
XCTAssertNil(cfg.fullSpreadAlgorithm) #expect(cfg.fullSpreadAlgorithm == nil)
} else { } else {
XCTAssertEqual(cfg.fullSpreadAlgorithm, expected) #expect(cfg.fullSpreadAlgorithm == expected)
} }
let back = ProfilingPreset( let back = ProfilingPreset(
id: "x", name: "n", description: "", id: "x", name: "n", description: "",
@@ -383,31 +414,30 @@ final class PresetMappingTests: XCTestCase {
calibrationFile: nil, calibrationFile: nil,
applyCalibration: nil applyCalibration: nil
) )
XCTAssertEqual(back.fullSpreadAlgorithm, value) #expect(back.fullSpreadAlgorithm == value)
}
} }
func testOfpsHighQualityFalse() { @Test("Explicit ofpsHighQuality=false is preserved, distinct from nil")
func ofpsHighQualityFalse() {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.ofpsHighQuality = false preset.ofpsHighQuality = false
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertEqual(cfg.ofpsHighQuality, false) #expect(cfg.ofpsHighQuality == false)
preset.ofpsHighQuality = nil preset.ofpsHighQuality = nil
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertNil(nilCfg.ofpsHighQuality) #expect(nilCfg.ofpsHighQuality == nil)
} }
func testLayoutMapping() { @Test("noRandomize/seed layout mapping rules", arguments: [
let cases: [(Bool?, Int?, LayoutOrder, Int)] = [ (true, nil, LayoutOrder.raster, 1),
(true, nil, .raster, 1),
(true, 7, .raster, 7), (true, 7, .raster, 7),
(false, nil, .deterministic, 1), (false, nil, .deterministic, 1),
(false, 1, .deterministic, 1), (false, 1, .deterministic, 1),
(nil, 1, .deterministic, 1), (nil, 1, .deterministic, 1),
(false, 5, .customSeed, 5) (false, 5, .customSeed, 5)
] ] as [(Bool?, Int?, LayoutOrder, Int)])
for (noRandomize, seed, layout, expectedSeed) in cases { func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.noRandomize = noRandomize preset.noRandomize = noRandomize
preset.randomSeed = seed preset.randomSeed = seed
@@ -415,35 +445,31 @@ final class PresetMappingTests: XCTestCase {
preset: preset, basename: "t", preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil workingDirectory: nil, calibrationFile: nil
) )
XCTAssertEqual(cfg.layoutOrder, layout) #expect(cfg.layoutOrder == layout)
XCTAssertEqual(cfg.customSeed, expectedSeed) #expect(cfg.customSeed == expectedSeed)
}
} }
func testCustomPageFallback() { @Test("Custom page fallback matrix", arguments: [
let cases: [(String, PageSize, Double, Double)] = [ ("250x300", PageSize.custom, 250.0, 300.0),
("250x300", .custom, 250.0, 300.0),
("50x50", .custom, 50.0, 50.0), ("50x50", .custom, 50.0, 50.0),
("foo", .a4, 210.0, 297.0), ("foo", .a4, 210.0, 297.0),
("30x40", .a4, 210.0, 297.0), ("30x40", .a4, 210.0, 297.0),
("210x", .a4, 210.0, 297.0) ("210x", .a4, 210.0, 297.0)
] ] as [(String, PageSize, Double, Double)])
for (raw, page, w, h) in cases { func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.pageSize = raw preset.pageSize = raw
let cfg = PrinttargConfig( let cfg = PrinttargConfig(
preset: preset, basename: "t", preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil workingDirectory: nil, calibrationFile: nil
) )
XCTAssertEqual(cfg.pageSize, page) #expect(cfg.pageSize == page)
XCTAssertEqual(cfg.customPageWidth, w) #expect(cfg.customPageWidth == w)
XCTAssertEqual(cfg.customPageHeight, h) #expect(cfg.customPageHeight == h)
}
} }
func testFwaToSelection() { @Test("FWA preset value → selection matrix", arguments: [
let cases: [(String?, ColprofFwaSelection)] = [ (nil, ColprofFwaSelection.none),
(nil, .none),
("none", .none), ("none", .none),
("NONE", .none), ("NONE", .none),
("", .empty), ("", .empty),
@@ -452,22 +478,19 @@ final class PresetMappingTests: XCTestCase {
("D65", .D65), ("D65", .D65),
("d65", .D65), ("d65", .D65),
("/tmp/fwa.sp", .custom) ("/tmp/fwa.sp", .custom)
] ] as [(String?, ColprofFwaSelection)])
for (raw, expected) in cases { func fwaToSelection(raw: String?, expected: ColprofFwaSelection) {
XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected) #expect(ColprofFwaSelection(presetValue: raw) == expected)
}
} }
func testFwaToPresetValue() { @Test("FWA selection → preset value matrix", arguments: [
let cases: [(ColprofFwaSelection, String?)] = [ (ColprofFwaSelection.none, nil),
(.none, nil),
(.empty, ""), (.empty, ""),
(.D50, "D50"), (.D50, "D50"),
(.D65, "D65"), (.D65, "D65"),
(.custom, "/tmp/fwa.sp") (.custom, "/tmp/fwa.sp")
] ] as [(ColprofFwaSelection, String?)])
for (selection, expected) in cases { func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) {
XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected) #expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected)
}
} }
} }
@@ -1,19 +1,21 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #82 preset application through the live view models, under an /// Issue #82 preset application through the live view models, under an
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager). /// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
@Suite("PresetViewModelMapping")
@MainActor @MainActor
final class PresetViewModelMappingTests: XCTestCase { struct PresetViewModelMappingTests {
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) { private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
let env = try TestAppEnvironment.make() let env = try TestAppEnvironment.make()
return (env, TargetWorkflowViewModel(environment: env.environment)) return (env, TargetWorkflowViewModel(environment: env.environment))
} }
func testNilFwaClearsCustomPath() throws { @Test("Applying a nil-FWA preset after a custom FWA clears the stale path")
func nilFwaClearsCustomPath() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -22,17 +24,18 @@ final class PresetViewModelMappingTests: XCTestCase {
colprofFwa: "/tmp/fwa.sp" colprofFwa: "/tmp/fwa.sp"
) )
vm.applyPreset(customPreset) vm.applyPreset(customPreset)
XCTAssertEqual(vm.profile.fwaSelection, .custom) #expect(vm.profile.fwaSelection == .custom)
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp") #expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp")
customPreset.colprofFwa = nil customPreset.colprofFwa = nil
vm.applyPreset(customPreset) vm.applyPreset(customPreset)
XCTAssertEqual(vm.profile.fwaSelection, .none) #expect(vm.profile.fwaSelection == .none)
XCTAssertEqual(vm.profile.fwaCustomPath, "") #expect(vm.profile.fwaCustomPath == "")
XCTAssertNil(vm.profile.fwaValue) #expect(vm.profile.fwaValue == nil)
} }
func testCustomFwaRoundTrip() throws { @Test("Custom FWA preset path survives the round-trip to colprof_fwa")
func customFwaRoundTrip() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -41,12 +44,13 @@ final class PresetViewModelMappingTests: XCTestCase {
colprofFwa: "/tmp/other.sp" colprofFwa: "/tmp/other.sp"
) )
vm.applyPreset(preset) vm.applyPreset(preset)
XCTAssertEqual(vm.profile.fwaSelection, .custom) #expect(vm.profile.fwaSelection == .custom)
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp") #expect(vm.profile.fwaCustomPath == "/tmp/other.sp")
XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp") #expect(vm.profile.fwaValue == "/tmp/other.sp")
} }
func testPresetCalibrationReachesStage2() throws { @Test("Preset calibration reaches Stage 2 instead of stale live state")
func presetCalibrationReachesStage2() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -61,12 +65,13 @@ final class PresetViewModelMappingTests: XCTestCase {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
XCTAssertTrue(vm.profile.applyCalibration) #expect(vm.profile.applyCalibration)
XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal") #expect(vm.profile.calibrationFile == "/tmp/preset.cal")
XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal") #expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal")
} }
func testDisabledCalibrationClearsStage2() throws { @Test("Preset with calibration disabled clears Stage 2 calibration")
func disabledCalibrationClearsStage2() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -80,11 +85,12 @@ final class PresetViewModelMappingTests: XCTestCase {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
XCTAssertFalse(vm.profile.applyCalibration) #expect(!vm.profile.applyCalibration)
XCTAssertNil(vm.buildPrinttargConfig().calibrationFile) #expect(vm.buildPrinttargConfig().calibrationFile == nil)
} }
func testFormFieldsApply() throws { @Test("Preset Stage 1/2 form fields apply to the live form")
func formFieldsApply() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -100,22 +106,22 @@ final class PresetViewModelMappingTests: XCTestCase {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
XCTAssertEqual(vm.colourSpace, .cmyk) #expect(vm.colourSpace == .cmyk)
XCTAssertEqual(vm.effectivePatchCount, 1500) #expect(vm.effectivePatchCount == 1500)
XCTAssertEqual(vm.whitePatches, 6) #expect(vm.whitePatches == 6)
XCTAssertEqual(vm.blackPatches, 8) #expect(vm.blackPatches == 8)
XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9) #expect(vm.greyStepsEnabled && vm.greySteps == 9)
XCTAssertEqual(vm.algorithm, .random) #expect(vm.algorithm == .random)
XCTAssertEqual(vm.tiffDpi, 150) #expect(vm.tiffDpi == 150)
XCTAssertEqual(vm.pageSize, .custom) #expect(vm.pageSize == .custom)
XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300) #expect(vm.customPageW == 250 && vm.customPageH == 300)
XCTAssertEqual(vm.selectedPresetID, "c-form") #expect(vm.selectedPresetID == "c-form")
// Disabled advanced controls stay nil in the snapshot, not // Disabled advanced controls stay nil in the snapshot, not
// numeric sentinels. // numeric sentinels.
preset.greySteps = nil preset.greySteps = nil
vm.applyPreset(preset) vm.applyPreset(preset)
XCTAssertFalse(vm.greyStepsEnabled) #expect(!vm.greyStepsEnabled)
XCTAssertNil(vm.buildTargenConfig().greySteps) #expect(vm.buildTargenConfig().greySteps == nil)
} }
} }
+17 -13
View File
@@ -1,12 +1,13 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result). /// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the /// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on. /// `UITestHooks` seam the UI tests rely on.
final class PrintPanelStubTests: XCTestCase { @Suite("PrintPanelStub")
struct PrintPanelStubTests {
private func withEnv( private func withEnv(
_ vars: [String: String?], _ vars: [String: String?],
@@ -27,17 +28,19 @@ final class PrintPanelStubTests: XCTestCase {
try body() try body()
} }
func testCancelIsNil() throws { @Test("Cancel returns nil — not an error")
func cancelIsNil() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel", "ICCERY_TEST_PRINT_PANEL": "cancel",
]) { ]) {
XCTAssertTrue(UITestHooks.printPanelStubbed) #expect(UITestHooks.printPanelStubbed)
XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q")) #expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
} }
} }
func testOkResult() throws { @Test("OK returns captured options + selected printer")
func okResult() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok", "ICCERY_TEST_PRINT_PANEL": "ok",
@@ -45,14 +48,15 @@ final class PrintPanelStubTests: XCTestCase {
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue", "ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "q") let result = UITestHooks.printPanelResult(forQueue: "q")
XCTAssertEqual(result?.selectedPrinter, "Other_Queue") #expect(result?.selectedPrinter == "Other_Queue")
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear") #expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
XCTAssertEqual(result?.options.mediaType, "Photo") #expect(result?.options.mediaType == "Photo")
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true) #expect(result?.options.ppdUncorrectedPassthrough == true)
} }
} }
func testOkDefaultsPrinter() throws { @Test("OK defaults selected printer to the opened queue")
func okDefaultsPrinter() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok", "ICCERY_TEST_PRINT_PANEL": "ok",
@@ -60,8 +64,8 @@ final class PrintPanelStubTests: XCTestCase {
"ICCERY_TEST_PANEL_PRINTER": nil, "ICCERY_TEST_PANEL_PRINTER": nil,
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue") let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
XCTAssertEqual(result?.selectedPrinter, "My_Queue") #expect(result?.selectedPrinter == "My_Queue")
XCTAssertNil(result?.options.cupsOptions) #expect(result?.options.cupsOptions == nil)
} }
} }
} }
+14 -29
View File
@@ -1,21 +1,24 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class PrintcalArgsTests: XCTestCase { @Suite("PrintcalArgs")
struct PrintcalArgsTests {
private let tmp = URL(fileURLWithPath: "/tmp/out.cal") private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
func testDefaults() throws { @Test("Default printcal argv")
func defaults() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "CAL_demo", ti3Basename: "CAL_demo",
outputURL: tmp outputURL: tmp
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) #expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
} }
func testAllOptions() throws { @Test("All options and channel limits")
func allOptions() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
@@ -29,7 +32,7 @@ final class PrintcalArgsTests: XCTestCase {
] ]
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
XCTAssertEqual(args, [ #expect(args == [
"-v", "-e", "-v", "-e",
"-I", "-z", "-I", "-z",
"-a", "/tmp/old.cal", "-a", "/tmp/old.cal",
@@ -41,34 +44,16 @@ final class PrintcalArgsTests: XCTestCase {
]) ])
} }
func testWhitespacePreviousCal() throws { @Test("Rejects invalid per-channel limit")
let config = PrintcalConfig( func rejectsBadChannelLimit() {
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " \n\t "
)
let args = try PrintcalArgs.build(config: config)
XCTAssertFalse(args.contains("-a"))
XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
}
func testPreviousCalTrimmed() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " /tmp/old.cal "
)
let args = try PrintcalArgs.build(config: config)
XCTAssertEqual(args[args.firstIndex(of: "-a")! + 1], "/tmp/old.cal")
}
func testRejectsBadChannelLimit() {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)] channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
) )
XCTAssertThrowsError(try PrintcalArgs.build(config: config)) #expect(throws: (any Error).self) {
_ = try PrintcalArgs.build(config: config)
}
} }
} }
+120 -107
View File
@@ -1,8 +1,9 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
final class PrinttargArgsTests: XCTestCase { @Suite("PrinttargArgs")
struct PrinttargArgsTests {
private func config( private func config(
instrument: PrintInstrument = .i1, instrument: PrintInstrument = .i1,
@@ -27,121 +28,119 @@ final class PrinttargArgsTests: XCTestCase {
) )
} }
func testBaseline() throws { @Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300")
func baseline() throws {
let args = try PrinttargArgs.build(config: config()) let args = try PrinttargArgs.build(config: config())
XCTAssertEqual(args, ["-v", "-u", "-i", "i1", "-p", "A4", #expect(args == ["-v", "-u", "-i", "i1", "-p", "A4",
"-R", "1", "-t", "300", "target"]) "-R", "1", "-t", "300", "target"])
} }
func testDeterministicDefault() throws { @Test("Default layout is deterministic -R 1, never bare")
func deterministicDefault() throws {
let args = try PrinttargArgs.build(config: config()) let args = try PrinttargArgs.build(config: config())
XCTAssertTrue(args.contains("-R")) #expect(args.contains("-R"))
XCTAssertFalse(args.contains("-r")) #expect(!args.contains("-r"))
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1") #expect(args[args.firstIndex(of: "-R")! + 1] == "1")
} }
func testCustomSeed() throws { @Test("Custom seed -R N; seed < 1 throws")
func customSeed() throws {
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42)) let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42") #expect(args[args.firstIndex(of: "-R")! + 1] == "42")
XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in #expect(throws: PrinttargArgError.self) {
XCTAssertTrue(error is PrinttargArgError) try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))
} }
} }
func testRaster() throws { @Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)")
func raster() throws {
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9)) let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
XCTAssertTrue(args.contains("-r")) #expect(args.contains("-r"))
XCTAssertFalse(args.contains("-R")) #expect(!args.contains("-R"))
} }
func testLabel() throws { @Test("Label: -d emits the resolved string, not a colour space")
func label() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04")) config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
let i = args.firstIndex(of: "-d")! let i = args.firstIndex(of: "-d")!
XCTAssertTrue(args[i + 1].hasPrefix("ICCery - t")) #expect(args[i + 1].hasPrefix("ICCery - t"))
} }
func testBitDepthAndDPI() throws { @Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600")
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600)) func bitDepthAndDPI() throws {
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
.contains("-T")) .contains("-T"))
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72)) #expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
.contains("-t")) .contains("-t"))
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in #expect(throws: PrinttargArgError.self) {
XCTAssertTrue(error is PrinttargArgError) try PrinttargArgs.build(config: config(dpi: 71))
} }
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in #expect(throws: PrinttargArgError.self) {
XCTAssertTrue(error is PrinttargArgError) try PrinttargArgs.build(config: config(dpi: 601))
} }
} }
func testInstruments() throws { @Test("All instruments emit their Argyll code")
func instruments() throws {
let expected: [(PrintInstrument, String)] = [ let expected: [(PrintInstrument, String)] = [
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"), (.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"), (.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
] ]
for (inst, code) in expected { for (inst, code) in expected {
let args = try PrinttargArgs.build(config: config(instrument: inst)) let args = try PrinttargArgs.build(config: config(instrument: inst))
XCTAssertEqual(args[args.firstIndex(of: "-i")! + 1], code) #expect(args[args.firstIndex(of: "-i")! + 1] == code)
} }
} }
func testPageSizes() throws { @Test("All fixed page sizes; custom emits WxH in mm")
func pageSizes() throws {
for size in PageSize.allCases where size != .custom { for size in PageSize.allCases where size != .custom {
let args = try PrinttargArgs.build(config: config(pageSize: size)) let args = try PrinttargArgs.build(config: config(pageSize: size))
XCTAssertEqual(args[args.firstIndex(of: "-p")! + 1], size.rawValue) #expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue)
} }
let custom = try PrinttargArgs.build(config: config( let custom = try PrinttargArgs.build(config: config(
pageSize: .custom, customW: 150, customH: 220)) pageSize: .custom, customW: 150, customH: 220))
XCTAssertEqual(custom[custom.firstIndex(of: "-p")! + 1], "150x220") #expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220")
} }
func testCustomPageTooSmall() { @Test("Custom page below 50 mm throws")
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in func customPageTooSmall() {
XCTAssertTrue(error is PrinttargArgError) #expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
} }
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in #expect(throws: PrinttargArgError.self) {
XCTAssertTrue(error is PrinttargArgError) try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))
} }
} }
func testCalibrationFlags() throws { @Test("Calibration: -K applies, -I embeds")
func calibrationFlags() throws {
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal")) let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal"))
XCTAssertEqual(k[k.firstIndex(of: "-K")! + 1], "/tmp/a.cal") #expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true)) let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal") #expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal")
XCTAssertFalse(i.contains("-K")) #expect(!i.contains("-K"))
} }
func testCalProtection() throws { @Test("CAL_ basename never gets -K or -I")
func calProtection() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(calFile: "/tmp/a.cal", basename: "CAL_test")) config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
XCTAssertFalse(args.contains("-K")) #expect(!args.contains("-K"))
XCTAssertFalse(args.contains("-I")) #expect(!args.contains("-I"))
} }
func testWhitespaceOptions() throws { @Test("Unsafe basename throws")
let args = try PrinttargArgs.build( func unsafeBasename() {
config: config(label: " \n ", calFile: " \t ")) #expect(throws: PathSecurity.Error.self) {
XCTAssertFalse(args.contains("-d")) try PrinttargArgs.build(config: config(basename: "../x"))
XCTAssertFalse(args.contains("-K"))
XCTAssertFalse(args.contains("-I"))
}
func testTrimmedOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " My Label ", calFile: " /tmp/a.cal "))
XCTAssertEqual(args[args.firstIndex(of: "-d")! + 1], "My Label")
XCTAssertEqual(args[args.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
}
func testUnsafeBasename() {
XCTAssertThrowsError(try PrinttargArgs.build(config: config(basename: "../x"))) { error in
XCTAssertTrue(error is PathSecurity.Error)
} }
} }
} }
final class PrinttargLabelTests: XCTestCase { @Suite("PrinttargLabel")
struct PrinttargLabelTests {
private var fixedDate: Date { private var fixedDate: Date {
var comps = DateComponents() var comps = DateComponents()
@@ -150,33 +149,37 @@ final class PrinttargLabelTests: XCTestCase {
return Calendar(identifier: .gregorian).date(from: comps)! return Calendar(identifier: .gregorian).date(from: comps)!
} }
func testAutomatic() { @Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM")
func automatic() {
let label = PrinttargLabel.automatic( let label = PrinttargLabel.automatic(
basename: "tgt", basename: "tgt",
metadata: TargetLabelMetadata( metadata: TargetLabelMetadata(
printer: "Epson", inkSet: "CMYK", printer: "Epson", inkSet: "CMYK",
driverPaper: "Photo", actualPaper: "Matte"), driverPaper: "Photo", actualPaper: "Matte"),
date: fixedDate, timeZone: .current) date: fixedDate, timeZone: .current)
XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - ")) #expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026")) #expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
} }
func testUnspecified() { @Test("Missing metadata becomes Unspecified")
func unspecified() {
let label = PrinttargLabel.automatic( let label = PrinttargLabel.automatic(
basename: "tgt", metadata: TargetLabelMetadata(), basename: "tgt", metadata: TargetLabelMetadata(),
date: fixedDate, timeZone: .current) date: fixedDate, timeZone: .current)
XCTAssertTrue(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - ")) #expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
} }
func testManualWins() { @Test("Manual label wins over automatic")
func manualWins() {
let resolved = PrinttargLabel.resolved( let resolved = PrinttargLabel.resolved(
customLabel: " My Label ", basename: "tgt", customLabel: " My Label ", basename: "tgt",
metadata: TargetLabelMetadata(), date: fixedDate) metadata: TargetLabelMetadata(), date: fixedDate)
XCTAssertEqual(resolved, "My Label") #expect(resolved == "My Label")
} }
} }
final class PrinttargManifestTests: XCTestCase { @Suite("PrinttargManifest")
struct PrinttargManifestTests {
private let prettySingle = """ private let prettySingle = """
Some log line Some log line
@@ -205,58 +208,66 @@ final class PrinttargManifestTests: XCTestCase {
} }
""" """
func testSinglePage() throws { @Test("Decodes a single-page pretty manifest amid log noise")
func singlePage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettySingle) let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
XCTAssertEqual(m.event, "manifest") #expect(m.event == "manifest")
XCTAssertEqual(m.pages.count, 1) #expect(m.pages.count == 1)
XCTAssertEqual(m.pages[0].filename, "target.tif") #expect(m.pages[0].filename == "target.tif")
XCTAssertEqual(m.pages[0].patches, 800) #expect(m.pages[0].patches == 800)
} }
func testMultiPage() throws { @Test("Multi-page manifest preserves order")
func multiPage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti) let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
XCTAssertEqual(m.pages.map(\.filename), ["p1.tif", "p2.tif"]) #expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"])
} }
func testNoJSON() { @Test("No JSON document → noJSONDocument")
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in func noJSON() {
XCTAssertTrue(error is ManifestError) #expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
} }
} }
func testWrongEvent() { @Test("Wrong event → wrongEvent")
func wrongEvent() {
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n" let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in #expect(throws: ManifestError.self) {
XCTAssertTrue(error is ManifestError) try PrinttargManifestExtractor.manifest(from: stdout)
} }
} }
func testRowColorsNotManifest() { @Test("ROW_COLORS_JSON line is never treated as the manifest")
func rowColorsNotManifest() {
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}" let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
// Extraction only starts at a '{' that begins a trimmed line, // Extraction only starts at a '{' that begins a trimmed line,
// so the ROW_COLORS_JSON line is skipped entirely. // so the ROW_COLORS_JSON line is skipped entirely.
let m = try? PrinttargManifestExtractor.manifest(from: stdout) let m = try? PrinttargManifestExtractor.manifest(from: stdout)
XCTAssertNotNil(m) #expect(m != nil)
XCTAssertEqual(m?.event, "manifest") #expect(m?.event == "manifest")
} }
func testBracesInFilename() throws { @Test("Braces inside a quoted filename do not corrupt the scan")
func bracesInFilename() throws {
let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n" let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n"
let m = try PrinttargManifestExtractor.manifest(from: stdout) let m = try PrinttargManifestExtractor.manifest(from: stdout)
XCTAssertEqual(m.pages[0].filename, "a}b.tif") #expect(m.pages[0].filename == "a}b.tif")
} }
func testUnsafeFilenames() { @Test("Unsafe / non-TIFF filenames rejected")
func unsafeFilenames() {
for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] { for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] {
let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}" let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}"
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in #expect(throws: ManifestError.self) {
XCTAssertTrue(error is ManifestError) try PrinttargManifestExtractor.manifest(from: stdout)
} }
} }
} }
} }
final class ArgyllRunnerPrinttargTests: XCTestCase { @Suite("ArgyllRunner Printtarg")
struct ArgyllRunnerPrinttargTests {
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL { private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -308,7 +319,8 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
try Data(bytes).write(to: url) try Data(bytes).write(to: url)
} }
func testSuccess() async throws { @Test("Successful printtarg emits .ti2 + manifest + PNG previews")
func success() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -327,17 +339,18 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
processManager: ProcessManager(), binaryResolver: resolver) processManager: ProcessManager(), binaryResolver: resolver)
let config = PrinttargConfig(basename: "pt", workingDirectory: dir) let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
let result = try await runner.runPrinttarg(config: config) let result = try await runner.runPrinttarg(config: config)
XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2") #expect(result.ti2URL.lastPathComponent == "pt.ti2")
XCTAssertEqual(result.manifest.pages.count, 1) #expect(result.manifest.pages.count == 1)
XCTAssertEqual(result.pages.count, 1) #expect(result.pages.count == 1)
let png = result.pages[0].previewPNG let png = result.pages[0].previewPNG
XCTAssertNotNil(png) #expect(png != nil)
if let png { if let png {
XCTAssertEqual(png.prefix(8), Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A])) #expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
} }
} }
func testFailure() async throws { @Test("Non-zero exit throws processFailed and stays on stage")
func failure() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
echo "oops" >&2 echo "oops" >&2
@@ -347,16 +360,14 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printtarg", code: 3, logs: ["oops"]))
} }
} }
func testNoManifest() async throws { @Test("Exit 0 without manifest → malformedManifest")
func noManifest() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -369,13 +380,14 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
} }
func testNoTi2() async throws { @Test("Exit 0 without .ti2 → missingArtefact")
func noTi2() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
@@ -385,13 +397,14 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
} }
func testDeterminism() async throws { @Test("Deterministic config produces byte-identical .ti2")
func determinism() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -411,6 +424,6 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
config: PrinttargConfig(basename: "b", workingDirectory: dir)) config: PrinttargConfig(basename: "b", workingDirectory: dir))
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2")) let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2")) let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
XCTAssertEqual(d1, d2) #expect(d1 == d2)
} }
} }
+117 -134
View File
@@ -1,11 +1,11 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Helpers shared across ProcessManager tests. Fixture binaries are shell /// Helpers shared across ProcessManager tests. Fixture binaries are shell
/// scripts written to a temp dir no resource bundling required. /// scripts written to a temp dir no resource bundling required.
/// XCTest executes test methods serially by default. @Suite("ProcessManager", .serialized)
final class ProcessManagerTests: XCTestCase { struct ProcessManagerTests {
// MARK: - Fixture plumbing // MARK: - Fixture plumbing
@@ -43,7 +43,7 @@ final class ProcessManagerTests: XCTestCase {
if box.finish() { cont.resume(returning: box.events) } if box.finish() { cont.resume(returning: box.events) }
} }
Task { Task {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) try? await Task.sleep(for: .seconds(timeout))
if box.finish() { cont.resume(returning: box.events) } if box.finish() { cont.resume(returning: box.events) }
} }
} }
@@ -84,7 +84,7 @@ final class ProcessManagerTests: XCTestCase {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if exitCount(in: box) > 0 { return true } if exitCount(in: box) > 0 { return true }
try? await Task.sleep(nanoseconds: 10_000_000) try? await Task.sleep(for: .milliseconds(10))
} }
return false return false
} }
@@ -93,7 +93,7 @@ final class ProcessManagerTests: XCTestCase {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true } if FileManager.default.fileExists(atPath: url.path) { return true }
try? await Task.sleep(nanoseconds: 10_000_000) try? await Task.sleep(for: .milliseconds(10))
} }
return false return false
} }
@@ -106,14 +106,14 @@ final class ProcessManagerTests: XCTestCase {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if await manager.isRunning(id) { return true } if await manager.isRunning(id) { return true }
try? await Task.sleep(nanoseconds: 10_000_000) try? await Task.sleep(for: .milliseconds(10))
} }
return false return false
} }
// MARK: - Tests // MARK: - Tests
func testStreamsStdoutAndEmitsExit() async throws { @Test func streamsStdoutAndEmitsExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n") let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
async let events = collect(pm, id: "t1") async let events = collect(pm, id: "t1")
@@ -122,21 +122,21 @@ final class ProcessManagerTests: XCTestCase {
let lines = evs.compactMap { e -> String? in let lines = evs.compactMap { e -> String? in
if case .stdout(_, let l) = e { return l }; return nil if case .stdout(_, let l) = e { return l }; return nil
} }
XCTAssertEqual(lines, ["hello", "world"]) #expect(lines == ["hello", "world"])
XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0))) #expect(evs.contains(.exit(id: "t1", code: 0)))
} }
func testRoutesStderrSeparately() async throws { @Test func routesStderrSeparately() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n") let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
async let evs = collect(pm, id: "t2") async let evs = collect(pm, id: "t2")
try await pm.runStreaming(id: "t2", binary: bin, arguments: []) try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
let events = await evs let events = await evs
XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out"))) #expect(events.contains(.stdout(id: "t2", line: "out")))
XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops"))) #expect(events.contains(.stderr(id: "t2", line: "oops")))
} }
func testStripsRowColorsJSONPrefix() async throws { @Test func stripsRowColorsJSONPrefix() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"rows.sh", "rows.sh",
@@ -149,22 +149,21 @@ final class ProcessManagerTests: XCTestCase {
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
XCTAssertEqual(rows, ["{\"row\":1}"]) #expect(rows == ["{\"row\":1}"])
XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain"))) #expect(events.contains(.stdout(id: "t3", line: "plain")))
// Prefixed lines must not leak into stdout. // Prefixed lines must not leak into stdout.
XCTAssertFalse(events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}"))) #expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
} }
func testUnterminatedTailFlushesOnExit() async throws { @Test func unterminatedTailFlushesOnExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n") let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
async let evs = collect(pm, id: "t4") async let evs = collect(pm, id: "t4")
try await pm.runStreaming(id: "t4", binary: bin, arguments: []) try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
let t4SawTail = await evs.contains(.stdout(id: "t4", line: "no-newline")) #expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
XCTAssertTrue(t4SawTail)
} }
func testStdinRoundTrip() async throws { @Test func stdinRoundTrip() async throws {
let pm = ProcessManager() let pm = ProcessManager()
// Read two lines then exit naturally a killed sh would lose its // Read two lines then exit naturally a killed sh would lose its
// buffered stdio output, which is exactly the chartread pattern. // buffered stdio output, which is exactly the chartread pattern.
@@ -177,23 +176,21 @@ final class ProcessManagerTests: XCTestCase {
try await pm.sendStdin(id: "t5", text: " \n") try await pm.sendStdin(id: "t5", text: " \n")
try await pm.sendStdin(id: "t5", text: "d\n") try await pm.sendStdin(id: "t5", text: "d\n")
let events = await evs let events = await evs
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: "))) #expect(events.contains(.stdout(id: "t5", line: "got: ")))
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d"))) #expect(events.contains(.stdout(id: "t5", line: "got:d")))
} }
func testDuplicateIDRejected() async throws { @Test func duplicateIDRejected() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n") let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "t6", binary: bin, arguments: []) try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
await assertAsyncThrows(expectedType: ProcessError.self) { await #expect(throws: ProcessError.duplicateID("t6")) {
try await pm.runStreaming(id: "t6", binary: bin, arguments: []) try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
} errorHandler: { error in
XCTAssertEqual(error, .duplicateID("t6"))
} }
await pm.kill(id: "t6") await pm.kill(id: "t6")
} }
func testKillEmitsExitAndClosesStdin() async throws { @Test func killEmitsExitAndClosesStdin() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n") let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
async let evs = collect(pm, id: "t7") async let evs = collect(pm, id: "t7")
@@ -202,51 +199,49 @@ final class ProcessManagerTests: XCTestCase {
let events = await evs let events = await evs
// exit emitted exactly once // exit emitted exactly once
let exits = events.filter { if case .exit = $0 { return true }; return false } let exits = events.filter { if case .exit = $0 { return true }; return false }
XCTAssertEqual(exits.count, 1) #expect(exits.count == 1)
await assertAsyncThrows(expectedType: ProcessError.self) { await #expect(throws: ProcessError.unknownID("t7")) {
try await pm.sendStdin(id: "t7", text: "d\n") try await pm.sendStdin(id: "t7", text: "d\n")
} errorHandler: { error in
XCTAssertEqual(error, .unknownID("t7"))
} }
} }
func testKillAllCountsSignaled() async throws { @Test func killAllCountsSignaled() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n") let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "a", binary: bin, arguments: []) try await pm.runStreaming(id: "a", binary: bin, arguments: [])
try await pm.runStreaming(id: "b", binary: bin, arguments: []) try await pm.runStreaming(id: "b", binary: bin, arguments: [])
let count = await pm.killAll() let count = await pm.killAll()
XCTAssertEqual(count, 2) #expect(count == 2)
} }
func testCapturedRunReturnsBothStreams() async throws { @Test func capturedRunReturnsBothStreams() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n") let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
XCTAssertTrue(result.stdout.contains("out-data")) #expect(result.stdout.contains("out-data"))
XCTAssertTrue(result.stderr.contains("err-data")) #expect(result.stderr.contains("err-data"))
XCTAssertEqual(result.exitCode, 3) #expect(result.exitCode == 3)
} }
func testCapturedRunFastExit() async throws { @Test func capturedRunFastExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n") let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
XCTAssertEqual(result.exitCode, 7) #expect(result.exitCode == 7)
XCTAssertEqual(result.stdout, "") #expect(result.stdout == "")
XCTAssertEqual(result.stderr, "") #expect(result.stderr == "")
} }
func testCapturedRunStderrOnly() async throws { @Test func capturedRunStderrOnly() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n") let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
XCTAssertEqual(result.exitCode, 1) #expect(result.exitCode == 1)
XCTAssertEqual(result.stdout, "") #expect(result.stdout == "")
XCTAssertTrue(result.stderr.contains("mock lp failure")) #expect(result.stderr.contains("mock lp failure"))
} }
func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws { @Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
let pm = ProcessManager() let pm = ProcessManager()
// 5000 lines each stream exceeds the 64 KiB pipe buffer. // 5000 lines each stream exceeds the 64 KiB pipe buffer.
let bin = try script( let bin = try script(
@@ -254,29 +249,26 @@ final class ProcessManagerTests: XCTestCase {
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n" "#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
) )
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
XCTAssertTrue(result.stdout.contains("out-4999")) #expect(result.stdout.contains("out-4999"))
XCTAssertTrue(result.stderr.contains("err-4999")) #expect(result.stderr.contains("err-4999"))
} }
func testArgyllEnvVarIsSet() async throws { @Test func argyllEnvVarIsSet() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n") let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
async let evs = collect(pm, id: "t10") async let evs = collect(pm, id: "t10")
try await pm.runStreaming(id: "t10", binary: bin, arguments: []) try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
let t10SawEnv = await evs.contains(.stdout(id: "t10", line: "ANI=1")) #expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
XCTAssertTrue(t10SawEnv)
} }
func testUnknownIDStdinThrows() async throws { @Test func unknownIDStdinThrows() async throws {
let pm = ProcessManager() let pm = ProcessManager()
await assertAsyncThrows(expectedType: ProcessError.self) { await #expect(throws: ProcessError.unknownID("nope")) {
try await pm.sendStdin(id: "nope", text: "d\n") try await pm.sendStdin(id: "nope", text: "d\n")
} errorHandler: { error in
XCTAssertEqual(error, .unknownID("nope"))
} }
} }
func testExplicitPartialFlushEmitsRowColorsJSON() async throws { @Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let marker = Self.fixtureDir let marker = Self.fixtureDir
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)") .appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
@@ -287,8 +279,7 @@ final class ProcessManagerTests: XCTestCase {
let box = Box() let box = Box()
let observer = observe(pm, id: "t11", into: box) let observer = observe(pm, id: "t11", into: box)
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path]) try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
let markerReady = await waitForFile(marker) #expect(await waitForFile(marker))
XCTAssertTrue(markerReady)
// Retry the flush so the pipe-ingest task can win the actor race // Retry the flush so the pipe-ingest task can win the actor race
// on a loaded host; the first successful flush emits the row. // on a loaded host; the first successful flush emits the row.
var flushed = false var flushed = false
@@ -298,25 +289,24 @@ final class ProcessManagerTests: XCTestCase {
flushed = true flushed = true
break break
} }
try await Task.sleep(nanoseconds: 20_000_000) try await Task.sleep(for: .milliseconds(20))
} }
XCTAssertTrue(flushed) #expect(flushed)
await pm.kill(id: "t11") await pm.kill(id: "t11")
let sawExit = await waitForExit(in: box) #expect(await waitForExit(in: box))
XCTAssertTrue(sawExit)
observer.cancel() observer.cancel()
let events = box.events let events = box.events
let rows = events.compactMap { e -> String? in let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
XCTAssertEqual(rows, ["{\"row\":9}"]) #expect(rows == ["{\"row\":9}"])
// Prefixed tails must not leak into stdout, even via finalize. // Prefixed tails must not leak into stdout, even via finalize.
XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}"))) #expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
XCTAssertEqual(exitCount(in: box), 1) #expect(exitCount(in: box) == 1)
} }
func testUnterminatedRowTailFinalizesAsJSONRow() async throws { @Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"row-tail.sh", "row-tail.sh",
@@ -325,70 +315,68 @@ final class ProcessManagerTests: XCTestCase {
let box = Box() let box = Box()
let observer = observe(pm, id: "t12", into: box) let observer = observe(pm, id: "t12", into: box)
try await pm.runStreaming(id: "t12", binary: bin, arguments: []) try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
let sawExit = await waitForExit(in: box) #expect(await waitForExit(in: box))
XCTAssertTrue(sawExit)
observer.cancel() observer.cancel()
let events = box.events let events = box.events
let rows = events.compactMap { e -> String? in let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
XCTAssertEqual(rows, ["{\"row\":42}"]) #expect(rows == ["{\"row\":42}"])
XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}"))) #expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
let rowIndex = events.firstIndex { let rowIndex = events.firstIndex {
if case .jsonRow = $0 { return true }; return false if case .jsonRow = $0 { return true }; return false
} }
let exitIndexes = events.indices.filter { let exitIndexes = events.indices.filter {
if case .exit = events[$0] { return true }; return false if case .exit = events[$0] { return true }; return false
} }
XCTAssertEqual(exitIndexes.count, 1) #expect(exitIndexes.count == 1)
if let rowIndex, let exitIndex = exitIndexes.first { if let rowIndex, let exitIndex = exitIndexes.first {
XCTAssertTrue(rowIndex < exitIndex) #expect(rowIndex < exitIndex)
} else { } else {
XCTFail("expected a jsonRow before the exit event") Issue.record("expected a jsonRow before the exit event")
} }
} }
func testFastStreamingExitEmitsExactlyOneExit() async throws { @Test func fastStreamingExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n") let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
let box = Box() let box = Box()
let observer = observe(pm, id: "t13", into: box) let observer = observe(pm, id: "t13", into: box)
try await pm.runStreaming(id: "t13", binary: bin, arguments: []) try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
let sawExit = await waitForExit(in: box) #expect(await waitForExit(in: box))
XCTAssertTrue(sawExit)
// The grace window must outlast the 2 s finalize watchdog so a // The grace window must outlast the 2 s finalize watchdog so a
// duplicate emission from it would be observed. // duplicate emission from it would be observed.
try await Task.sleep(nanoseconds: 2_500_000_000) try await Task.sleep(for: .milliseconds(2500))
observer.cancel() observer.cancel()
XCTAssertEqual(box.events, [.exit(id: "t13", code: 0)]) #expect(box.events == [.exit(id: "t13", code: 0)])
} }
func testFastCapturedExitEmitsExactlyOneExit() async throws { @Test func fastCapturedExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n") let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
let box = Box() let box = Box()
let observer = observe(pm, id: "t14", into: box) let observer = observe(pm, id: "t14", into: box)
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
XCTAssertEqual(result.exitCode, 7) #expect(result.exitCode == 7)
// Both the termination handler and the waitUntilExit watchdog // Both the termination handler and the waitUntilExit watchdog
// resume the same box; give the slower path time to fire. // resume the same box; give the slower path time to fire.
try await Task.sleep(nanoseconds: 500_000_000) try await Task.sleep(for: .milliseconds(500))
observer.cancel() observer.cancel()
XCTAssertEqual(box.events, [.exit(id: "t14", code: 7)]) #expect(box.events == [.exit(id: "t14", code: 7)])
} }
func testCapturedRunSetsArgyllNotInteractive() async throws { @Test func capturedRunSetsArgyllNotInteractive() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"cap-env.sh", "cap-env.sh",
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n" "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
) )
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
XCTAssertEqual(result.stdout, "ANI=1\n") #expect(result.stdout == "ANI=1\n")
} }
func testKillAllTerminatesStreamingAndCapturedChildren() async throws { @Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let marker = Self.fixtureDir let marker = Self.fixtureDir
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)") .appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
@@ -402,88 +390,83 @@ final class ProcessManagerTests: XCTestCase {
let capTask = Task { let capTask = Task {
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path]) try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
} }
let markerReady = await waitForFile(marker) #expect(await waitForFile(marker))
XCTAssertTrue(markerReady) #expect(await waitForRunning(pm, id: "t16"))
let t16Running = await waitForRunning(pm, id: "t16") #expect(await waitForRunning(pm, id: "t17"))
XCTAssertTrue(t16Running) #expect(await pm.killAll() == 2)
let t17Running = await waitForRunning(pm, id: "t17")
XCTAssertTrue(t17Running)
let killed = await pm.killAll()
XCTAssertEqual(killed, 2)
_ = try await capTask.value _ = try await capTask.value
let streamExit = await waitForExit(in: streamBox) #expect(await waitForExit(in: streamBox))
XCTAssertTrue(streamExit) #expect(await waitForExit(in: capBox))
let capExit = await waitForExit(in: capBox)
XCTAssertTrue(capExit)
// Grace window outlasts the streaming finalize watchdog. // Grace window outlasts the streaming finalize watchdog.
try await Task.sleep(nanoseconds: 2_500_000_000) try await Task.sleep(for: .milliseconds(2500))
streamObserver.cancel() streamObserver.cancel()
capObserver.cancel() capObserver.cancel()
let t16RunningAfter = await pm.isRunning("t16") #expect(!(await pm.isRunning("t16")))
XCTAssertFalse(t16RunningAfter) #expect(!(await pm.isRunning("t17")))
let t17RunningAfter = await pm.isRunning("t17") #expect(exitCount(in: streamBox) == 1)
XCTAssertFalse(t17RunningAfter) #expect(exitCount(in: capBox) == 1)
XCTAssertEqual(exitCount(in: streamBox), 1)
XCTAssertEqual(exitCount(in: capBox), 1)
} }
} }
final class ProcessLineDecoderTests: XCTestCase { @Suite("ProcessLineDecoder")
func testSplitsAcrossChunkBoundaries() { struct ProcessLineDecoderTests {
@Test func splitsAcrossChunkBoundaries() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
XCTAssertEqual(d.feed(Data("he".utf8)), []) #expect(d.feed(Data("he".utf8)) == [])
XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"]) #expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"]) #expect(d.feed(Data("ld\n".utf8)) == ["world"])
XCTAssertNil(d.finish()) #expect(d.finish() == nil)
} }
func testCrlfIsStripped() { @Test func crlfIsStripped() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"]) #expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
} }
func testFinishReturnsRemainder() { @Test func finishReturnsRemainder() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
_ = d.feed(Data("x".utf8)) _ = d.feed(Data("x".utf8))
XCTAssertEqual(d.finish(), "x") #expect(d.finish() == "x")
XCTAssertNil(d.finish()) #expect(d.finish() == nil)
} }
} }
final class JSONAccumulatorTests: XCTestCase { @Suite("JSONAccumulator")
func testMultilinePrettyJSON() { struct JSONAccumulatorTests {
@Test func multilinePrettyJSON() {
var acc = JSONAccumulator() var acc = JSONAccumulator()
XCTAssertNil(acc.feed(line: "{")) #expect(acc.feed(line: "{") == nil)
XCTAssertNil(acc.feed(line: " \"k\": 1")) #expect(acc.feed(line: " \"k\": 1") == nil)
let done = acc.feed(line: "}") let done = acc.feed(line: "}")
XCTAssertNotNil(done) #expect(done != nil)
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int] let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
XCTAssertEqual(obj?["k"], 1) #expect(obj?["k"] == 1)
} }
func testNonJSONLinesIgnored() { @Test func nonJSONLinesIgnored() {
var acc = JSONAccumulator() var acc = JSONAccumulator()
XCTAssertNil(acc.feed(line: "Reading instrument...")) #expect(acc.feed(line: "Reading instrument...") == nil)
XCTAssertNil(acc.feed(line: "still text")) #expect(acc.feed(line: "still text") == nil)
XCTAssertNil(acc.completeData) #expect(acc.completeData == nil)
} }
func testDecodeTyped() { @Test func decodeTyped() {
struct Doc: Decodable { let n: Int } struct Doc: Decodable { let n: Int }
var acc = JSONAccumulator() var acc = JSONAccumulator()
// Split so the doc completes on the second feed. // Split so the doc completes on the second feed.
XCTAssertNil(acc.feed(line: "{\"n\":")) #expect(acc.feed(line: "{\"n\":") == nil)
let data = acc.feed(line: "7}") let data = acc.feed(line: "7}")
XCTAssertNotNil(data) #expect(data != nil)
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) } let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
XCTAssertEqual(doc?.n, 7) #expect(doc?.n == 7)
XCTAssertTrue(acc.isEmpty) #expect(acc.isEmpty)
} }
} }
final class LogSanitizerTests: XCTestCase { @Suite("LogSanitizer")
func testHomeIsRewritten() { struct LogSanitizerTests {
@Test func homeIsRewritten() {
let path = "\(NSHomeDirectory())/Documents/foo.ti1" let path = "\(NSHomeDirectory())/Documents/foo.ti1"
XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1") #expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
} }
} }
@@ -1,66 +0,0 @@
import Foundation
import XCTest
@testable import ICCery
/// Direct contracts for the shared logged-run helper (issue #80).
///
/// `runLogged` owns the running-flag transition (`false true false`)
/// and the log-reset decision; these tests pin both sides of the
/// contract plus the coalesced `@MainActor` log hop.
@MainActor
final class ProcessRunSupportTests: XCTestCase {
private struct SentinelError: Error {}
func testSuccessTransitions() async throws {
var running: [Bool] = []
var resets = 0
var received: [String] = []
let result = try await ProcessRunSupport.runLogged(
setRunning: { running.append($0) },
resetLog: { resets += 1 },
onLog: { batch in
// MainActor.assertIsolated() needs Swift 5.9; the runner is
// on Xcode 14.2 (Swift 5.7) (#115).
XCTAssertTrue(Thread.isMainThread)
received.append(contentsOf: batch)
}
) { onLog in
onLog(["alpha", "beta"])
return 42
}
XCTAssertEqual(result, 42)
XCTAssertEqual(running, [true, false])
XCTAssertEqual(resets, 1)
// The sink hops back through a main-actor Task; yield until the
// coalesced batch lands.
for _ in 0..<200 where received.isEmpty {
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertEqual(received, ["alpha", "beta"])
}
func testFailureTransitions() async throws {
var running: [Bool] = []
var resets = 0
do {
_ = try await ProcessRunSupport.runLogged(
setRunning: { running.append($0) },
resetLog: { resets += 1 },
onLog: { _ in }
) { _ -> Int in
throw SentinelError()
}
XCTFail("Expected runLogged to rethrow")
} catch is SentinelError {
// Expected path.
}
XCTAssertEqual(running, [true, false])
XCTAssertEqual(resets, 1)
}
}
@@ -1,15 +1,17 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class ProfcheckArgsTests: XCTestCase { @Suite("ProfcheckArgs")
struct ProfcheckArgsTests {
func testArgv() throws { @Test("Hard-coded argv")
func argv() throws {
let config = ProfcheckConfig( let config = ProfcheckConfig(
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"), ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
iccURL: URL(fileURLWithPath: "/tmp/target.icc") iccURL: URL(fileURLWithPath: "/tmp/target.icc")
) )
let args = try ProfcheckArgs.build(config: config) let args = try ProfcheckArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"]) #expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
} }
} }
@@ -1,39 +1,43 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class ProfcheckParserTests: XCTestCase { @Suite("ProfcheckParser")
struct ProfcheckParserTests {
func testJsonReport() { @Test("Prefers JSON report with de2000 keys")
func jsonReport() {
let output = """ let output = """
No of test patches = 52 No of test patches = 52
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02} {"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99 Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
XCTAssertEqual(report.isValid, true) #expect(report.isValid == true)
XCTAssertEqual(report.patchCount, 52) #expect(report.patchCount == 52)
XCTAssertEqual(report.avgDE, 0.85) #expect(report.avgDE == 0.85)
XCTAssertEqual(report.maxDE, 2.41) #expect(report.maxDE == 2.41)
XCTAssertEqual(report.rmsDE, 1.02) #expect(report.rmsDE == 1.02)
XCTAssertEqual(report.status, .excellent) #expect(report.status == .excellent)
} }
func testLegacyText() { @Test("Falls back to legacy text")
func legacyText() {
let output = """ let output = """
No of test patches = 120 No of test patches = 120
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95 Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
XCTAssertEqual(report.isValid, true) #expect(report.isValid == true)
XCTAssertEqual(report.patchCount, 120) #expect(report.patchCount == 120)
XCTAssertEqual(report.avgDE, 1.80) #expect(report.avgDE == 1.80)
XCTAssertEqual(report.maxDE, 3.50) #expect(report.maxDE == 3.50)
XCTAssertEqual(report.rmsDE, 0.95) #expect(report.rmsDE == 0.95)
XCTAssertEqual(report.status, .good) #expect(report.status == .good)
} }
func testRegexFallback() { @Test("Broad regex fallback")
func regexFallback() {
let output = """ let output = """
No of test patches = 10 No of test patches = 10
avg = 4.25 avg = 4.25
@@ -41,25 +45,27 @@ final class ProfcheckParserTests: XCTestCase {
rms = 2.30 rms = 2.30
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
XCTAssertEqual(report.isValid, true) #expect(report.isValid == true)
XCTAssertEqual(report.avgDE, 4.25) #expect(report.avgDE == 4.25)
XCTAssertEqual(report.maxDE, 6.10) #expect(report.maxDE == 6.10)
XCTAssertEqual(report.rmsDE, 2.30) #expect(report.rmsDE == 2.30)
XCTAssertEqual(report.status, .poor) #expect(report.status == .poor)
} }
func testUnparseable() { @Test("Unparseable output warns, not zeros")
func unparseable() {
let output = "some random text without metrics" let output = "some random text without metrics"
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
XCTAssertEqual(report.isValid, false) #expect(report.isValid == false)
XCTAssertNotNil(report.warning) #expect(report.warning != nil)
XCTAssertNil(report.avgDE) #expect(report.avgDE == nil)
} }
func testStatusBands() { @Test("Status bands")
XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent) func statusBands() {
XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good) #expect(VerificationStatus.from(avgDE: 0.5) == .excellent)
XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable) #expect(VerificationStatus.from(avgDE: 1.5) == .good)
XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor) #expect(VerificationStatus.from(avgDE: 2.5) == .acceptable)
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
/// A `FileManager` subclass that reports a temporary directory as the /// A `FileManager` subclass that reports a temporary directory as the
@@ -18,7 +18,8 @@ private final class TestFileManager: FileManager {
} }
} }
final class ProfileInstallerTests: XCTestCase { @Suite("ProfileInstaller")
struct ProfileInstallerTests {
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let fm = FileManager.default let fm = FileManager.default
@@ -38,7 +39,8 @@ final class ProfileInstallerTests: XCTestCase {
return url return url
} }
func testUserInstall() throws { @Test("Installs .icc to user ColorSync folder")
func userInstall() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -49,14 +51,15 @@ final class ProfileInstallerTests: XCTestCase {
fileManager: testFM fileManager: testFM
) )
XCTAssertTrue(result.registered) #expect(result.registered)
XCTAssertFalse(result.overwritten) #expect(!result.overwritten)
XCTAssertFalse(result.renamed) #expect(!result.renamed)
XCTAssertTrue(result.destPath.hasSuffix("test.icc")) #expect(result.destPath.hasSuffix("test.icc"))
XCTAssertTrue(fm.fileExists(atPath: result.destPath)) #expect(fm.fileExists(atPath: result.destPath))
} }
func testOverwriteSucceeds() throws { @Test("Overwrite succeeds and replaces the existing file")
func overwriteSucceeds() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -67,7 +70,7 @@ final class ProfileInstallerTests: XCTestCase {
config: InstallProfileConfig(sourceURL: source), config: InstallProfileConfig(sourceURL: source),
fileManager: testFM fileManager: testFM
) )
XCTAssertFalse(first.overwritten) #expect(!first.overwritten)
// Change the source contents. // Change the source contents.
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) } let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
@@ -84,14 +87,15 @@ final class ProfileInstallerTests: XCTestCase {
fileManager: testFM fileManager: testFM
) )
XCTAssertTrue(second.overwritten) #expect(second.overwritten)
XCTAssertFalse(second.renamed) #expect(!second.renamed)
XCTAssertTrue(fm.fileExists(atPath: second.destPath)) #expect(fm.fileExists(atPath: second.destPath))
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath)) let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
XCTAssertEqual(Array(installed), newBytes) #expect(Array(installed) == newBytes)
} }
func testPreservesIcmExtension() throws { @Test("Preserves .icm source extension")
func preservesIcmExtension() throws {
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icm") let source = try makeSource(at: tmp, name: "m5_profile.icm")
@@ -101,11 +105,12 @@ final class ProfileInstallerTests: XCTestCase {
fileManager: testFM fileManager: testFM
) )
XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm") #expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm")
XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm")) #expect(result.destPath.hasSuffix("m5_profile.icm"))
} }
func testRejectsParentTraversal() throws { @Test("Rejects parent traversal in source path")
func rejectsParentTraversal() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
@@ -120,19 +125,20 @@ final class ProfileInstallerTests: XCTestCase {
let sourceURL = tmp let sourceURL = tmp
.appendingPathComponent("..") .appendingPathComponent("..")
.appendingPathComponent(naughtyName) .appendingPathComponent(naughtyName)
XCTAssertTrue(fm.fileExists(atPath: sourceURL.path)) #expect(fm.fileExists(atPath: sourceURL.path))
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
XCTFail("Expected unsafeStem error") Issue.record("Expected unsafeStem error")
} catch let error as ProfileInstallError { } catch let error as ProfileInstallError {
if case .unsafeStem = error { } else { XCTFail("Expected unsafeStem, got \(error)") } if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
} catch { } catch {
XCTFail("Unexpected error type: \(error)") Issue.record("Unexpected error type: \(error)")
} }
} }
func testAllowsDoubleDotStem() throws { @Test("Allows stems with consecutive dots like foo..bar")
func allowsDoubleDotStem() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -143,22 +149,23 @@ final class ProfileInstallerTests: XCTestCase {
fileManager: testFM fileManager: testFM
) )
XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc")) #expect(result.destPath.hasSuffix("foo..bar.icc"))
XCTAssertTrue(fm.fileExists(atPath: result.destPath)) #expect(fm.fileExists(atPath: result.destPath))
} }
func testRejectsSmallSource() throws { @Test("Rejects source files that are too small")
func rejectsSmallSource() throws {
let tmp = try makeTempDir() let tmp = try makeTempDir()
let source = tmp.appendingPathComponent("tiny.icc") let source = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: source) try Data(repeating: 0, count: 64).write(to: source)
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
XCTFail("Expected sourceTooSmall error") Issue.record("Expected sourceTooSmall error")
} catch let error as ProfileInstallError { } catch let error as ProfileInstallError {
if case .sourceTooSmall = error { } else { XCTFail("Expected sourceTooSmall, got \(error)") } if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
} catch { } catch {
XCTFail("Unexpected error type: \(error)") Issue.record("Unexpected error type: \(error)")
} }
} }
} }
+57 -54
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
private func tempStoreURL() -> URL { private func tempStoreURL() -> URL {
@@ -8,90 +8,92 @@ private func tempStoreURL() -> URL {
.appendingPathComponent("settings.json") .appendingPathComponent("settings.json")
} }
final class AppSettingsTests: XCTestCase { @Suite("AppSettings")
func testDefaults() { struct AppSettingsTests {
@Test func defaults() {
let s = AppSettings.default let s = AppSettings.default
XCTAssertNil(s.argyllBinaryDir) #expect(s.argyllBinaryDir == nil)
XCTAssertNil(s.defaultInstrument) #expect(s.defaultInstrument == nil)
XCTAssertNil(s.logLevel) #expect(s.logLevel == nil)
XCTAssertEqual(s.deltaEGoodMax, 2.0) #expect(s.deltaEGoodMax == 2.0)
XCTAssertEqual(s.deltaEWarningMax, 5.0) #expect(s.deltaEWarningMax == 5.0)
XCTAssertTrue(s.customPresets.isEmpty) #expect(s.customPresets.isEmpty)
XCTAssertFalse(s.enableI1Pro2Leds) #expect(!s.enableI1Pro2Leds)
XCTAssertEqual(s.calibrationStaleDays, 30) #expect(s.calibrationStaleDays == 30)
XCTAssertEqual(s.defaultInstallLocation, .user) #expect(s.defaultInstallLocation == .user)
XCTAssertTrue(s.askBeforeOverwriteProfile) #expect(s.askBeforeOverwriteProfile)
XCTAssertFalse(s.openColorPanelAfterInstall) #expect(!s.openColorPanelAfterInstall)
XCTAssertTrue(s.isValid) #expect(s.isValid)
} }
func testNegativeThresholds() { @Test func negativeThresholds() {
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = -1 s.deltaEGoodMax = -1
XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE]) #expect(s.validate() == [AppSettings.errorNegativeDeltaE])
s.deltaEGoodMax = 2.0 s.deltaEGoodMax = 2.0
s.deltaEWarningMax = -0.5 s.deltaEWarningMax = -0.5
// -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too // -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too
XCTAssertTrue(s.validate() == [ #expect(s.validate() == [
AppSettings.errorNegativeDeltaE, AppSettings.errorNegativeDeltaE,
AppSettings.errorThresholdOrder, AppSettings.errorThresholdOrder,
]) ])
} }
func testGoodMustBeStrictlyLessThanWarning() { @Test func goodMustBeStrictlyLessThanWarning() {
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 5.0 s.deltaEGoodMax = 5.0
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder]) #expect(s.validate() == [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 6.0 s.deltaEGoodMax = 6.0
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder]) #expect(s.validate() == [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 4.9 s.deltaEGoodMax = 4.9
XCTAssertTrue(s.isValid) #expect(s.isValid)
} }
func testSnakeCaseKeys() throws { @Test func snakeCaseKeys() throws {
let s = AppSettings.default let s = AppSettings.default
let data = try JSONEncoder().encode(s) let data = try JSONEncoder().encode(s)
let json = String(data: data, encoding: .utf8)! let json = String(data: data, encoding: .utf8)!
XCTAssertTrue(json.contains("\"delta_e_good_max\"")) #expect(json.contains("\"delta_e_good_max\""))
XCTAssertTrue(json.contains("\"default_install_location\"")) #expect(json.contains("\"default_install_location\""))
XCTAssertTrue(json.contains("\"enable_i1pro2_leds\"")) #expect(json.contains("\"enable_i1pro2_leds\""))
} }
} }
final class SettingsStoreTests: XCTestCase { @Suite("SettingsStore")
func testRoundTrip() throws { struct SettingsStoreTests {
@Test func roundTrip() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 1.5 s.deltaEGoodMax = 1.5
s.defaultInstrument = "p3" s.defaultInstrument = "p3"
try store.save(s) try store.save(s)
XCTAssertEqual(store.load(), s) #expect(store.load() == s)
} }
func testCorruptJsonFallsBackToDefaults() throws { @Test func corruptJsonFallsBackToDefaults() throws {
let url = tempStoreURL() let url = tempStoreURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "{ not json".write(to: url, atomically: true, encoding: .utf8) try "{ not json".write(to: url, atomically: true, encoding: .utf8)
XCTAssertEqual(SettingsStore(fileURL: url).load(), .default) #expect(SettingsStore(fileURL: url).load() == .default)
} }
func testMissingFileReturnsDefaults() { @Test func missingFileReturnsDefaults() {
XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default) #expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
} }
func testInvalidSettingsNotPersisted() throws { @Test func invalidSettingsNotPersisted() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 9.0 // >= warning 5.0 s.deltaEGoodMax = 9.0 // >= warning 5.0
XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) } #expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) #expect(!FileManager.default.fileExists(atPath: url.path))
} }
func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws { @Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var valid = AppSettings.default var valid = AppSettings.default
@@ -107,13 +109,13 @@ final class SettingsStoreTests: XCTestCase {
var invalid = AppSettings.default var invalid = AppSettings.default
invalid.deltaEGoodMax = 9.0 invalid.deltaEGoodMax = 9.0
XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) } #expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
XCTAssertEqual(try Data(contentsOf: url), originalBytes) #expect(try Data(contentsOf: url) == originalBytes)
XCTAssertFalse(fired) #expect(!fired)
XCTAssertEqual(store.load(), valid) #expect(store.load() == valid)
} }
func testSavePostsNotification() async throws { @Test func savePostsNotification() async throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var fired = false var fired = false
@@ -122,11 +124,12 @@ final class SettingsStoreTests: XCTestCase {
) { _ in fired = true } ) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) } defer { NotificationCenter.default.removeObserver(token) }
try store.save(.default) try store.save(.default)
XCTAssertTrue(fired) #expect(fired)
} }
} }
final class LogSinkTests: XCTestCase { @Suite("LogSink")
struct LogSinkTests {
private func tempLog() -> (URL, LogSink) { private func tempLog() -> (URL, LogSink) {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-log-\(UUID().uuidString)") .appendingPathComponent("iccery-log-\(UUID().uuidString)")
@@ -134,26 +137,26 @@ final class LogSinkTests: XCTestCase {
return (url, LogSink(fileURL: url)) return (url, LogSink(fileURL: url))
} }
func testWritesFormattedLines() { @Test func writesFormattedLines() {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.debug) sink.setLevel(.debug)
sink.write(level: .info, category: "test", message: "hello") sink.write(level: .info, category: "test", message: "hello")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
XCTAssertTrue(content.contains("[INFO] test: hello")) #expect(content.contains("[INFO] test: hello"))
} }
func testLevelFilteringIsLive() { @Test func levelFilteringIsLive() {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.error) sink.setLevel(.error)
sink.write(level: .info, category: "t", message: "hidden") sink.write(level: .info, category: "t", message: "hidden")
sink.setLevel(.info) // runtime change, no restart (#158) sink.setLevel(.info) // runtime change, no restart (#158)
sink.write(level: .info, category: "t", message: "shown") sink.write(level: .info, category: "t", message: "shown")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
XCTAssertFalse(content.contains("hidden")) #expect(!content.contains("hidden"))
XCTAssertTrue(content.contains("shown")) #expect(content.contains("shown"))
} }
func testRotatesAt5MiBKeeping5Segments() throws { @Test func rotatesAt5MiBKeeping5Segments() throws {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.trace) sink.setLevel(.trace)
// Pre-fill the active log just under the cap, then cross it. // Pre-fill the active log just under the cap, then cross it.
@@ -164,20 +167,20 @@ final class LogSinkTests: XCTestCase {
try big.write(to: url, atomically: true, encoding: .utf8) try big.write(to: url, atomically: true, encoding: .utf8)
sink.write(level: .info, category: "t", message: "trigger rotation") sink.write(level: .info, category: "t", message: "trigger rotation")
XCTAssertTrue(FileManager.default.fileExists( #expect(FileManager.default.fileExists(
atPath: url.appendingPathExtension("1").path atPath: url.appendingPathExtension("1").path
)) ))
// Active log is small again. // Active log is small again.
let size = try FileManager.default.attributesOfItem( let size = try FileManager.default.attributesOfItem(
atPath: url.path atPath: url.path
)[.size] as? UInt64 )[.size] as? UInt64
XCTAssertTrue((size ?? 0) < 1024) #expect((size ?? 0) < 1024)
} }
func testTailExcerptCaps() throws { @Test func tailExcerptCaps() throws {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.debug) sink.setLevel(.debug)
sink.write(level: .info, category: "t", message: "line") sink.write(level: .info, category: "t", message: "line")
XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8) #expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
} }
} }
+60 -75
View File
@@ -1,10 +1,12 @@
import XCTest import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
final class TargenArgsTests: XCTestCase { @Suite("TargenArgs")
struct TargenArgsTests {
func testRgbBaseline() throws { @Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4")
func rgbBaseline() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -13,11 +15,12 @@ final class TargenArgsTests: XCTestCase {
basename: "test_rgb" basename: "test_rgb"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"]) #expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
XCTAssertFalse(args.contains("-u")) #expect(!args.contains("-u"))
} }
func testCmykBaseline() throws { @Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0")
func cmykBaseline() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 1500, patchCount: 1500,
@@ -26,10 +29,11 @@ final class TargenArgsTests: XCTestCase {
basename: "test_cmyk" basename: "test_cmyk"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"]) #expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
} }
func testCustomPatchCount() throws { @Test("Custom patch count honours -f (#44)")
func customPatchCount() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 2500, patchCount: 2500,
@@ -38,11 +42,12 @@ final class TargenArgsTests: XCTestCase {
basename: "custom_patches" basename: "custom_patches"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
XCTAssertTrue(args.contains("-f")) #expect(args.contains("-f"))
XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500") #expect(args[args.firstIndex(of: "-f")! + 1] == "2500")
} }
func testAllAdvancedFlags() throws { @Test("All advanced flags in stable order")
func allAdvancedFlags() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 1200, patchCount: 1200,
@@ -80,10 +85,11 @@ final class TargenArgsTests: XCTestCase {
"-p", "2.00", "-p", "2.00",
"advanced_cmyk" "advanced_cmyk"
] ]
XCTAssertEqual(args, expected) #expect(args == expected)
} }
func testRgbIgnoresInkLimit() throws { @Test("RGB ignores total ink limit")
func rgbIgnoresInkLimit() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -93,10 +99,11 @@ final class TargenArgsTests: XCTestCase {
basename: "rgb_no_ink" basename: "rgb_no_ink"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
XCTAssertFalse(args.contains("-l")) #expect(!args.contains("-l"))
} }
func testNeutralConcentrationOmittedWhenDefault() throws { @Test("Neutral concentration omitted when approximately 0.50")
func neutralConcentrationOmittedWhenDefault() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -106,10 +113,11 @@ final class TargenArgsTests: XCTestCase {
basename: "n_default" basename: "n_default"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
XCTAssertFalse(args.contains("-N")) #expect(!args.contains("-N"))
} }
func testAdaptationEmittedAtPointOne() throws { @Test("Adaptation emitted even at 0.10 (no default-skip)")
func adaptationEmittedAtPointOne() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -119,11 +127,12 @@ final class TargenArgsTests: XCTestCase {
basename: "a_flag" basename: "a_flag"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
XCTAssertTrue(args.contains("-A")) #expect(args.contains("-A"))
XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10") #expect(args[args.firstIndex(of: "-A")! + 1] == "0.10")
} }
func testOfpsEmitsNoFlag() throws { @Test("OFPS full spread algorithm emits no flag")
func ofpsEmitsNoFlag() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -133,11 +142,12 @@ final class TargenArgsTests: XCTestCase {
basename: "ofps_test" basename: "ofps_test"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
XCTAssertFalse(args.contains("ofps")) #expect(!args.contains("ofps"))
XCTAssertFalse(args.contains("-t")) #expect(!args.contains("-t"))
} }
func testDarkEmphasisAndPowerOmittedWhenOne() throws { @Test("Dark emphasis and device power omitted when 1.0")
func darkEmphasisAndPowerOmittedWhenOne() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -148,37 +158,12 @@ final class TargenArgsTests: XCTestCase {
basename: "defaults_omitted" basename: "defaults_omitted"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
XCTAssertFalse(args.contains("-V")) #expect(!args.contains("-V"))
XCTAssertFalse(args.contains("-p")) #expect(!args.contains("-p"))
} }
func testWhitespacePreconditioner() throws { @Test("Invalid basename throws")
let config = TargenConfig( func invalidBasenameThrows() {
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
preconditioningProfile: " \n\t ",
basename: "ws_pre"
)
let args = try TargenArgs.build(config: config)
XCTAssertFalse(args.contains("-c"))
}
func testPreconditionerTrimmed() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
preconditioningProfile: " /path/to/profile.icc ",
basename: "trim_pre"
)
let args = try TargenArgs.build(config: config)
XCTAssertEqual(args[args.firstIndex(of: "-c")! + 1], "/path/to/profile.icc")
}
func testInvalidBasenameThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -186,12 +171,13 @@ final class TargenArgsTests: XCTestCase {
blackPatches: 4, blackPatches: 4,
basename: "../bad_name" basename: "../bad_name"
) )
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in #expect(throws: PathSecurity.Error.self) {
XCTAssertTrue(error is PathSecurity.Error) try TargenArgs.build(config: config)
} }
} }
func testInvalidPatchCountThrows() { @Test("Invalid patch count throws")
func invalidPatchCountThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 0, patchCount: 0,
@@ -199,12 +185,13 @@ final class TargenArgsTests: XCTestCase {
blackPatches: 4, blackPatches: 4,
basename: "bad_count" basename: "bad_count"
) )
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in #expect(throws: TargenArgError.self) {
XCTAssertTrue(error is TargenArgError) try TargenArgs.build(config: config)
} }
} }
func testInvalidInkLimitThrows() { @Test("Invalid ink limit throws for CMYK")
func invalidInkLimitThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 800, patchCount: 800,
@@ -213,15 +200,17 @@ final class TargenArgsTests: XCTestCase {
totalInkLimit: 450, totalInkLimit: 450,
basename: "bad_ink" basename: "bad_ink"
) )
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in #expect(throws: TargenArgError.self) {
XCTAssertTrue(error is TargenArgError) try TargenArgs.build(config: config)
} }
} }
} }
final class ArgyllRunnerTargenTests: XCTestCase { @Suite("ArgyllRunner Targen")
struct ArgyllRunnerTargenTests {
func testSuccessfulTargenExecution() async throws { @Test("Successful targen execution creates .ti1 and returns URL")
func successfulTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -267,13 +256,14 @@ final class ArgyllRunnerTargenTests: XCTestCase {
box.append(batch) box.append(batch)
} }
logLines = box.lines logLines = box.lines
XCTAssertTrue(logLines.contains("Generating patches...")) #expect(logLines.contains("Generating patches..."))
XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path)) #expect(FileManager.default.fileExists(atPath: ti1URL.path))
XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1") #expect(ti1URL.lastPathComponent == "mock_test.ti1")
} }
func testFailedTargenExecution() async throws { @Test("Failed targen execution throws processFailed")
func failedTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -300,15 +290,13 @@ final class ArgyllRunnerTargenTests: XCTestCase {
workingDirectory: tempDir workingDirectory: tempDir
) )
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { await #expect(throws: ArgyllRunnerError.self) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "targen", code: 1, logs: ["Error: something went wrong"]))
} }
} }
func testMissingArtefactThrows() async throws { @Test("Targen exit 0 without .ti1 throws missingArtefact")
func missingArtefactThrows() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -335,11 +323,8 @@ final class ArgyllRunnerTargenTests: XCTestCase {
workingDirectory: tempDir workingDirectory: tempDir
) )
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { await #expect(throws: ArgyllRunnerError.self) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(
tempDir.appendingPathComponent("no_file.ti1").path))
} }
} }
} }
@@ -1,39 +0,0 @@
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Dataset-import error contracts through the
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
/// failures must surface identically as a single `.error` Notice.
@MainActor
final class TargetWorkflowViewModelTests: XCTestCase {
func testMalformedDatasetNotice() throws {
let env = try TestAppEnvironment.make()
defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment)
let bad = env.root.appendingPathComponent("broken.ti3")
try Data("this is not CGATS data".utf8).write(to: bad)
vm.importMeasurementDataset(from: bad)
let notice = try XCTUnwrap(vm.wizard.notice)
XCTAssertEqual(notice.kind, .error)
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
}
func testMissingDatasetNotice() throws {
let env = try TestAppEnvironment.make()
defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment)
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
vm.importMeasurementDataset(from: missing)
let notice = try XCTUnwrap(vm.wizard.notice)
XCTAssertEqual(notice.kind, .error)
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
}
}
@@ -1,3 +1,4 @@
import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
@@ -1,10 +1,12 @@
import Foundation import Foundation
import XCTest import Testing
@testable import ICCeryCore @testable import ICCeryCore
final class VerificationHistoryStoreTests: XCTestCase { @Suite("VerificationHistoryStore")
struct VerificationHistoryStoreTests {
func testAppendAndCap() async throws { @Test("Append and cap")
func appendAndCap() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -27,11 +29,12 @@ final class VerificationHistoryStoreTests: XCTestCase {
} }
let all = await store.all() let all = await store.all()
XCTAssertEqual(all.count, 3) #expect(all.count == 3)
XCTAssertEqual(all.first?.avgDE, 2.0) #expect(all.first?.avgDE == 2.0)
} }
func testParseFailurePreservesFile() async { @Test("Parse failure preserves file")
func parseFailurePreservesFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -42,13 +45,14 @@ final class VerificationHistoryStoreTests: XCTestCase {
let store = VerificationHistoryStore(url: url) let store = VerificationHistoryStore(url: url)
do { do {
_ = try await store.load() _ = try await store.load()
XCTFail("load() should throw on invalid JSON") Issue.record("load() should throw on invalid JSON")
} catch { } catch {
XCTAssertTrue(fm.fileExists(atPath: url.path)) #expect(fm.fileExists(atPath: url.path))
} }
} }
func testAppendLoadsExisting() async throws { @Test("Append loads existing records first")
func appendLoadsExisting() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -85,12 +89,13 @@ final class VerificationHistoryStoreTests: XCTestCase {
_ = try await store2.append(new) _ = try await store2.append(new)
let all = await store2.all() let all = await store2.all()
XCTAssertEqual(all.count, 2) #expect(all.count == 2)
XCTAssertTrue(all.contains { $0.id == "vr-existing" }) #expect(all.contains { $0.id == "vr-existing" })
XCTAssertTrue(all.contains { $0.id == "vr-new" }) #expect(all.contains { $0.id == "vr-new" })
} }
func testAppendPreservesUnparseableFile() async { @Test("Append does not overwrite an unparseable file")
func appendPreservesUnparseableFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -114,19 +119,20 @@ final class VerificationHistoryStoreTests: XCTestCase {
do { do {
_ = try await store.append(record) _ = try await store.append(record)
XCTFail("append() should propagate the load error") Issue.record("append() should propagate the load error")
} catch { } catch {
XCTAssertTrue(fm.fileExists(atPath: url.path)) #expect(fm.fileExists(atPath: url.path))
if let data = try? Data(contentsOf: url), if let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8) { let contents = String(data: data, encoding: .utf8) {
XCTAssertEqual(contents, badJSON) #expect(contents == badJSON)
} else { } else {
XCTFail("Could not read preserved file") Issue.record("Could not read preserved file")
} }
} }
} }
func testClearPreservesUnparseableFile() async { @Test("Clear does not overwrite an unparseable file")
func clearPreservesUnparseableFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -138,14 +144,15 @@ final class VerificationHistoryStoreTests: XCTestCase {
let store = VerificationHistoryStore(url: url) let store = VerificationHistoryStore(url: url)
do { do {
try await store.clear() try await store.clear()
XCTFail("clear() should propagate the load error") Issue.record("clear() should propagate the load error")
} catch { } catch {
let contents = try? String(contentsOf: url, encoding: .utf8) let contents = try? String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(contents, badJSON) #expect(contents == badJSON)
} }
} }
func testIso8601RoundTrip() async throws { @Test("ISO-8601 timestamps round-trip through a fresh store")
func iso8601RoundTrip() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -167,15 +174,16 @@ final class VerificationHistoryStoreTests: XCTestCase {
_ = try await store1.append(record) _ = try await store1.append(record)
let text = try String(contentsOf: url, encoding: .utf8) let text = try String(contentsOf: url, encoding: .utf8)
XCTAssertTrue(text.contains(ISO8601DateFormatter().string(from: timestamp))) #expect(text.contains(ISO8601DateFormatter().string(from: timestamp)))
let store2 = VerificationHistoryStore(url: url) let store2 = VerificationHistoryStore(url: url)
let loaded = try await store2.load() let loaded = try await store2.load()
XCTAssertEqual(loaded.count, 1) #expect(loaded.count == 1)
XCTAssertEqual(loaded.first?.timestamp, timestamp) #expect(loaded.first?.timestamp == timestamp)
} }
func testCsvQuoting() async throws { @Test("CSV export quoting")
func csvQuoting() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -196,7 +204,7 @@ final class VerificationHistoryStoreTests: XCTestCase {
_ = try await store.append(record) _ = try await store.append(record)
let csv = await store.exportCSV() let csv = await store.exportCSV()
XCTAssertTrue(csv.contains("\"a,b\"")) #expect(csv.contains("\"a,b\""))
XCTAssertTrue(csv.contains("\"\"quoted\"\"")) #expect(csv.contains("\"\"quoted\"\""))
} }
} }
@@ -1,12 +1,13 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #29 `CAL_` basename must be restored on relaunch and on any /// Issue #29 `CAL_` basename must be restored on relaunch and on any
/// attempt to navigate to a non-calibration stage that would use it. /// attempt to navigate to a non-calibration stage that would use it.
@Suite("WizardCalibrationSession")
@MainActor @MainActor
final class WizardCalibrationSessionTests: XCTestCase { struct WizardCalibrationSessionTests {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
@@ -23,7 +24,8 @@ final class WizardCalibrationSessionTests: XCTestCase {
return url return url
} }
func testRelaunchRestoresOriginal() throws { @Test("Persist and restore calibrationOriginalBasename across a relaunch")
func relaunchRestoresOriginal() throws {
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
var saved = WizardState( var saved = WizardState(
@@ -37,13 +39,14 @@ final class WizardCalibrationSessionTests: XCTestCase {
let model = WizardViewModel(stateStore: store) let model = WizardViewModel(stateStore: store)
XCTAssertEqual(model.basename, "DemoTarget") #expect(model.basename == "DemoTarget")
XCTAssertEqual(model.calibrationOriginalBasename, "") #expect(model.calibrationOriginalBasename == "")
XCTAssertEqual(model.sessionMode, .profile) #expect(model.sessionMode == .profile)
XCTAssertEqual(model.stage, .generate) #expect(model.stage == .generate)
} }
func testGoToBuildProfileRefusesAndRestores() throws { @Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original")
func goToBuildProfileRefusesAndRestores() throws {
let dir = try tempDir() let dir = try tempDir()
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
@@ -57,13 +60,14 @@ final class WizardCalibrationSessionTests: XCTestCase {
model.go(to: .buildProfile) model.go(to: .buildProfile)
XCTAssertEqual(model.basename, "DemoTarget") #expect(model.basename == "DemoTarget")
XCTAssertEqual(model.calibrationOriginalBasename, "") #expect(model.calibrationOriginalBasename == "")
XCTAssertEqual(model.sessionMode, .profile) #expect(model.sessionMode == .profile)
XCTAssertEqual(model.stage, .calibrate) #expect(model.stage == .calibrate)
} }
func testGoToLayoutStaysCal() throws { @Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration")
func goToLayoutStaysCal() throws {
let dir = try tempDir() let dir = try tempDir()
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
@@ -77,9 +81,9 @@ final class WizardCalibrationSessionTests: XCTestCase {
model.go(to: .layOutPrint) model.go(to: .layOutPrint)
XCTAssertEqual(model.basename, "CAL_DemoTarget") #expect(model.basename == "CAL_DemoTarget")
XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget") #expect(model.calibrationOriginalBasename == "DemoTarget")
XCTAssertEqual(model.sessionMode, .calibration) #expect(model.sessionMode == .calibration)
XCTAssertEqual(model.stage, .layOutPrint) #expect(model.stage == .layOutPrint)
} }
} }
+49 -47
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
private func artefacts( private func artefacts(
@@ -16,75 +16,77 @@ private func artefacts(
return a return a
} }
final class WizardGatingTests: XCTestCase { @Suite("WizardGating matrix")
struct WizardGatingTests {
func testEmptyProjectOnlyStage1() { @Test func emptyProjectOnlyStage1() {
let a = artefacts() let a = artefacts()
XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a)) #expect(WizardGating.isUnlocked(.generate, artefacts: a))
XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a)) #expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] { for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
XCTAssertFalse(WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked") #expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
} }
} }
func testTi1UnlocksStage2Only() { @Test func ti1UnlocksStage2Only() {
let a = artefacts(ti1: true) let a = artefacts(ti1: true)
XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) #expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a)) #expect(!WizardGating.isUnlocked(.measure, artefacts: a))
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a)) #expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a)) #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
} }
func testStage3NeedsTi1AndTi2() { @Test func stage3NeedsTi1AndTi2() {
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) #expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
XCTAssertTrue(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true))) #expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
} }
func testStage4NeedsTi3NotTi2() { @Test func stage4NeedsTi3NotTi2() {
// #109/#110: .ti2 alone must never unlock Stage 4. // #109/#110: .ti2 alone must never unlock Stage 4.
let a = artefacts(ti1: true, ti2: true) let a = artefacts(ti1: true, ti2: true)
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a)) #expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) #expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
} }
func testStage5NeedsTi3AndProfile() { @Test func stage5NeedsTi3AndProfile() {
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
XCTAssertTrue(WizardGating.isUnlocked( #expect(WizardGating.isUnlocked(
.verifyInstall, artefacts: artefacts(ti3: true, profile: true) .verifyInstall, artefacts: artefacts(ti3: true, profile: true)
)) ))
} }
func testForwardGatedBackwardFree() { @Test func forwardGatedBackwardFree() {
let a = artefacts() let a = artefacts()
XCTAssertFalse(WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a)) #expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
// Backward always allowed even when artefacts vanished. // Backward always allowed even when artefacts vanished.
XCTAssertTrue(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a)) #expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
// Same stage is a no-op. // Same stage is a no-op.
XCTAssertTrue(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a)) #expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
// Stage 0 is a side-trip, never gated. // Stage 0 is a side-trip, never gated.
XCTAssertTrue(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a)) #expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
} }
func testDeepestUnlocked() { @Test func deepestUnlocked() {
XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate) #expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
XCTAssertEqual(WizardGating.deepestUnlocked( #expect(WizardGating.deepestUnlocked(
artefacts: artefacts(ti1: true, ti2: true) artefacts: artefacts(ti1: true, ti2: true)
), .measure) ) == .measure)
XCTAssertEqual(WizardGating.deepestUnlocked( #expect(WizardGating.deepestUnlocked(
artefacts: artefacts(ti3: true, profile: true) artefacts: artefacts(ti3: true, profile: true)
), .verifyInstall) ) == .verifyInstall)
} }
} }
final class WizardStateStoreTests: XCTestCase { @Suite("WizardStateStore")
struct WizardStateStoreTests {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)") .appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
.appendingPathComponent("wizard_state.json") .appendingPathComponent("wizard_state.json")
} }
func testRoundTrip() throws { @Test func roundTrip() throws {
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
var s = WizardState() var s = WizardState()
@@ -95,43 +97,43 @@ final class WizardStateStoreTests: XCTestCase {
s.profileBasename = "imported" s.profileBasename = "imported"
s.calibrationOriginalBasename = "pre-cal" s.calibrationOriginalBasename = "pre-cal"
try store.save(s) try store.save(s)
XCTAssertEqual(store.load(), s) #expect(store.load() == s)
} }
func testMissingFileDefaults() { @Test func missingFileDefaults() {
let s = WizardStateStore(fileURL: tempURL()).load() let s = WizardStateStore(fileURL: tempURL()).load()
XCTAssertEqual(s, .default) #expect(s == .default)
XCTAssertEqual(s.stage, .generate) #expect(s.stage == .generate)
XCTAssertEqual(s.sessionMode, .profile) #expect(s.sessionMode == .profile)
} }
func testCorruptStageFallsBackToGenerate() throws { @Test func corruptStageFallsBackToGenerate() throws {
let url = tempURL() let url = tempURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"# try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
.write(to: url, atomically: true, encoding: .utf8) .write(to: url, atomically: true, encoding: .utf8)
XCTAssertEqual(WizardStateStore(fileURL: url).load().stage, .generate) #expect(WizardStateStore(fileURL: url).load().stage == .generate)
} }
func testCorruptJsonReturnsDefaultAndKeepsBytes() throws { @Test func corruptJsonReturnsDefaultAndKeepsBytes() throws {
let url = tempURL() let url = tempURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "not json".write(to: url, atomically: true, encoding: .utf8) try "not json".write(to: url, atomically: true, encoding: .utf8)
XCTAssertEqual(WizardStateStore(fileURL: url).load(), .default) #expect(WizardStateStore(fileURL: url).load() == .default)
let kept = try String(contentsOf: url, encoding: .utf8) let kept = try String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(kept, "not json") #expect(kept == "not json")
} }
func testSessionModeCalibrationRoundTrips() 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)
let decoded = try JSONDecoder().decode(WizardState.self, from: data) let decoded = try JSONDecoder().decode(WizardState.self, from: data)
XCTAssertEqual(decoded.sessionMode, .calibration) #expect(decoded.sessionMode == .calibration)
s.sessionMode = .profile s.sessionMode = .profile
XCTAssertEqual(s.sessionMode, .profile) #expect(s.sessionMode == .profile)
} }
} }
@@ -1,21 +0,0 @@
import XCTest
extension XCTestCase {
func assertAsyncThrows<T, E: Error>(
expectedType: E.Type,
_ expression: () async throws -> T,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath,
line: UInt = #line,
errorHandler: ((E) -> Void)? = nil
) async {
do {
_ = try await expression()
XCTFail("Expected \(expectedType) to be thrown but expression succeeded. \(message())", file: file, line: line)
} catch let error as E {
errorHandler?(error)
} catch {
XCTFail("Expected \(expectedType) but caught \(type(of: error)): \(error). \(message())", file: file, line: line)
}
}
}
@@ -133,18 +133,6 @@ final class Milestone2UITests: XCTestCase {
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5)) XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
} }
/// Stage 1/2 process-log containers resolve under the shared
/// `ProcessLogView` identifiers (issue #80).
func testProcessLogContainersResolve() throws {
launchApp()
XCTAssertTrue(waitFor("targenLogContainer").exists)
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
XCTAssertTrue(element("printtargLogContainer").exists)
}
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2. /// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
func testTargenFixtureUnlocksStage2() throws { func testTargenFixtureUnlocksStage2() throws {
launchApp() launchApp()
@@ -146,8 +146,6 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(notice.waitForExistence(timeout: 10)) XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "") XCTAssertTrue((notice.value as? String ?? "")
.contains("cancelled")) .contains("cancelled"))
// Cancellation is informational, never an error (#80).
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
} }
/// Preferences OK captured options are replayed verbatim in the /// Preferences OK captured options are replayed verbatim in the
@@ -216,8 +214,6 @@ final class Milestone3UITests: XCTestCase {
let notice = app.staticTexts.containing(predicate).firstMatch let notice = app.staticTexts.containing(predicate).firstMatch
XCTAssertTrue(notice.waitForExistence(timeout: 10)) XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue(notice.label.contains("Print failed")) XCTAssertTrue(notice.label.contains("Print failed"))
// Spool failure exposes the .error kind on the icon (#80).
XCTAssertEqual(element("printNotificationIcon").value as? String, "error")
} }
/// wizardState.printerName records the queue used for spooling (#95). /// wizardState.printerName records the queue used for spooling (#95).
@@ -140,57 +140,4 @@ final class Milestone4UITests: XCTestCase {
} }
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
} }
/// Two passes + a failing `average` run promote pass 1 to the
/// canonical .ti3 and show the sticky finish error notice via
/// `chartreadFinishNotice` (issue #80).
func testTwoPassAverageFailurePromotesFirstPass() throws {
app.launchEnvironment["MOCK_AVERAGE_FAIL"] = "1"
reachStage3()
app.buttons["btnDetectInstruments"].click()
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
driveOnePass(startButton: "btnStartRead")
_ = waitFor("chartreadAveragingPanel", timeout: 20)
driveOnePass(startButton: "btnMeasureAnotherSheet")
XCTAssertTrue(waitFor("btnFinishAndAverage", timeout: 20).exists)
app.buttons["btnFinishAndAverage"].click()
// Averaging failed pass 1 is promoted to the canonical .ti3
// and the sticky error notice stays on Stage 3.
let ti3 = workDir.appendingPathComponent("mytarget.ti3")
let deadline = Date().addingTimeInterval(20)
while Date() < deadline, !FileManager.default.fileExists(atPath: ti3.path) {
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
let notice = element("chartreadFinishNotice")
XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertEqual(notice.value as? String, "error")
}
/// Runs the mock handheld chartread session to completion
/// (start calibrate strip A strip B Done & Save).
private func driveOnePass(startButton: String) {
let start = app.buttons[startButton]
XCTAssertTrue(start.waitForExistence(timeout: 10))
let deadline = Date().addingTimeInterval(10)
while Date() < deadline, !start.isEnabled {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(start.isEnabled)
start.click()
_ = waitFor("btnCalibrate", timeout: 25)
app.buttons["btnCalibrate"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
_ = waitFor("btnDoneRead", timeout: 20)
app.buttons["btnDoneRead"].firstMatch.click()
}
} }
@@ -111,21 +111,4 @@ final class Milestone5UITests: XCTestCase {
"Expected verification status, got '\(statusValue)'" "Expected verification status, got '\(statusValue)'"
) )
} }
/// A failing colprof run surfaces through the session-wide wizard
/// notice only no duplicate stage-local error view (issue #80).
func testProfileFailureShowsWizardNotice() throws {
app.launchEnvironment["ICCERY_MOCK_COLPROF_EXIT"] = "2"
launchApp()
let create = waitFor("btnCreateProfile")
XCTAssertTrue(create.isEnabled)
create.click()
let notice = element("noticeText")
XCTAssertTrue(notice.waitForExistence(timeout: 20))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Profile creation failed"))
XCTAssertFalse(element("colprofLastError").exists)
}
} }
@@ -32,7 +32,6 @@ final class Milestone6CalibrationUITests: XCTestCase {
"ICCERY_TEST_WORKDIR": testWorkDir.path "ICCERY_TEST_WORKDIR": testWorkDir.path
] ]
app.launch() app.launch()
app.activate()
} }
override func tearDown() async throws { override func tearDown() async throws {
@@ -76,69 +75,6 @@ final class Milestone6CalibrationUITests: XCTestCase {
// After generation the wizard should advance to Stage 2 (layout) because // After generation the wizard should advance to Stage 2 (layout) because
// a CAL_ .ti1 now exists and the session is in calibration mode. // a CAL_ .ti1 now exists and the session is in calibration mode.
let layout = app.buttons["btnCreateLayout"] let layout = app.buttons["btnCreateLayout"]
if !layout.waitForExistence(timeout: 25) {
// The generate tap can be dropped while the dashboard is still
// settling after the stage transition; retry once before failing.
if calGenerate.waitForExistence(timeout: 2) {
calGenerate.tap()
}
XCTAssertTrue(layout.waitForExistence(timeout: 25)) XCTAssertTrue(layout.waitForExistence(timeout: 25))
} }
}
/// A failing calibration targen surfaces the error through the
/// wizard notice and restores the original basename (issue #80).
func testCalibrationTargenFailureRestoresBasename() throws {
let testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("cal-fail-\(UUID().uuidString)")
let appData = testRoot.appendingPathComponent("AppData")
try FileManager.default.createDirectory(
at: appData, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: testRoot) }
// Pre-stage wizard state so the failing mock targen is only
// exercised by the calibration run, not target generation.
let state: [String: Any] = [
"currentStage": 1,
"basename": "DemoTarget",
"cwd": testWorkDir.path,
"sessionMode": "profile",
"calibrationOriginalBasename": ""
]
let stateURL = appData.appendingPathComponent("wizard_state.json")
try JSONSerialization.data(withJSONObject: state).write(to: stateURL)
app.terminate()
app.launchEnvironment["ICCERY_TEST_ROOT"] = testRoot.path
app.launchEnvironment["ICCERY_MOCK_TARGEN_EXIT"] = "2"
app.launch()
app.activate()
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
let calGenerate = app.buttons["btnCalGenerate"]
XCTAssertTrue(calGenerate.waitForExistence(timeout: 10))
calGenerate.tap()
let notice = app.descendants(matching: .any)["noticeText"]
XCTAssertTrue(notice.waitForExistence(timeout: 20))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Calibration target failed"))
// The pre-CAL_ basename is restored and persisted.
let deadline = Date().addingTimeInterval(10)
var restoredBasename: String?
while Date() < deadline {
if let data = try? Data(contentsOf: stateURL),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let basename = object["basename"] as? String {
restoredBasename = basename
if basename == "DemoTarget" { break }
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertEqual(restoredBasename, "DemoTarget")
}
} }
+12 -12
View File
@@ -2,7 +2,7 @@ name: ICCery
options: options:
bundleIdPrefix: com.gronod bundleIdPrefix: com.gronod
deploymentTarget: deploymentTarget:
macOS: "12.0" macOS: "14.0"
groupSortPosition: top groupSortPosition: top
packages: packages:
@@ -13,7 +13,7 @@ targets:
ICCery: ICCery:
type: application type: application
platform: macOS platform: macOS
deploymentTarget: "12.0" deploymentTarget: "14.0"
sources: sources:
- path: Sources/ICCery - path: Sources/ICCery
- path: Resources - path: Resources
@@ -46,7 +46,7 @@ targets:
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: ICCery INFOPLIST_KEY_CFBundleDisplayName: ICCery
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0" MARKETING_VERSION: "2.0.0"
@@ -58,15 +58,15 @@ targets:
ENABLE_APP_SANDBOX: NO ENABLE_APP_SANDBOX: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_VERSION: "5.0" SWIFT_VERSION: "6.0"
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"] SWIFT_STRICT_CONCURRENCY: complete
MACOSX_DEPLOYMENT_TARGET: "12.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
ARCHS: "$(ARCHS_STANDARD)" ARCHS: "$(ARCHS_STANDARD)"
ICCeryCoreTests: ICCeryCoreTests:
type: bundle.unit-test type: bundle.unit-test
platform: macOS platform: macOS
deploymentTarget: "12.0" deploymentTarget: "14.0"
sources: sources:
- path: Tests/ICCeryCoreTests - path: Tests/ICCeryCoreTests
dependencies: dependencies:
@@ -79,13 +79,13 @@ targets:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery" TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "5.0" SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "12.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
ICCeryUITests: ICCeryUITests:
type: bundle.ui-testing type: bundle.ui-testing
platform: macOS platform: macOS
deploymentTarget: "12.0" deploymentTarget: "14.0"
sources: sources:
- path: Tests/ICCeryUITests - path: Tests/ICCeryUITests
dependencies: dependencies:
@@ -95,8 +95,8 @@ targets:
TEST_TARGET_NAME: ICCery TEST_TARGET_NAME: ICCery
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "5.0" SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "12.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
schemes: schemes:
ICCery: ICCery:
-40
View File
@@ -1,40 +0,0 @@
#!/bin/sh
# scripts/ensure-host-tools.sh
#
# Bootstrap host tools needed by CI on the macOS 12 runner:
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
#
# Safe to run repeatedly: existing tools are left alone.
set -eu
XCODEGEN_VERSION="2.38.0"
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
echo "==> Ensuring dmgbuild"
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
if command -v xcodegen >/dev/null 2>&1; then
echo "==> xcodegen already on PATH: $(xcodegen --version)"
else
echo "==> Installing xcodegen $XCODEGEN_VERSION (prebuilt)"
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
ZIP="$TMP/xcodegen-$XCODEGEN_VERSION.zip"
curl -fL --retry 3 \
"https://github.com/yonaskolb/XcodeGen/releases/download/$XCODEGEN_VERSION/xcodegen.zip" \
-o "$ZIP"
rm -rf "$INSTALL_ROOT"
mkdir -p "$INSTALL_ROOT"
# Zip contains xcodegen/{bin/xcodegen,share/xcodegen/SettingPresets};
# XcodeGen resolves its presets relative to the binary, so keep the tree.
unzip -q "$ZIP" -d "$INSTALL_ROOT"
BIN_DIR="$INSTALL_ROOT/xcodegen/bin"
chmod +x "$BIN_DIR/xcodegen"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$BIN_DIR" >> "$GITHUB_PATH"
fi
PATH="$BIN_DIR:$PATH"
echo "==> Installed: $("$BIN_DIR/xcodegen" --version)"
fi