Compare commits

...
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 150d094632 test(m9): convert pure core test suites from Swift Testing to XCTest
Slice 2 (Phase 1a) of the M9 Monterey retarget. Converts all pure Core
suites (argv builders, CGATS/CUPS/profcheck/gamut-mesh parsers, colour
math, file/artefact helpers, classifiers) to XCTestCase, and adds the
shared assertAsyncThrows helper for the async-throws sites that Slice 3
will convert.

Deferred to Slice 3 (kept on Swift Testing in mixed files):
ProcessManagerTests, ArgyllRunner*Tests, ProcessRunSupportTests,
store/UI suites, and the runner suites embedded in TargenTests /
PrinttargTests / ProcessManagerTests.

Also qualifies six bare `throw .case` expressions in CGATSParser with
CGATSParseError — they only compiled under typed throws, which Slice 1
demoted to untyped `throws`; without this the ICCeryCore target fails to
build and no test gate can run.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 20:43:42 +01:00
gronod 3184c50fb9 Merge pull request 'chore(m9): toolchain, CI config, and core syntax demotion (Slice 1)' (#105) from feat/m9-slice1-config-and-core-syntax into milestone/m9-monterey 2026-09-11 20:26:52 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b2b85ae835 chore(m9): retarget project spec, CI, and core syntax to macOS 12 / Swift 5.7
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 20:02:57 +01:00
gronod 09f433106d Merge pull request 'Milestone 8 — UAT-ready hardening and consolidation (#79–#86)' (#104) from milestone/m8-consolidation into develop
macOS CI / build-and-test (push) Successful in 13m1s
macOS CI / package (push) Successful in 4m25s
2026-09-11 17:10:23 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 891a504ee7 test(ui): activate app after launch in calibration UI tests
macOS CI / build-and-test (pull_request) Successful in 11m22s
macOS CI / package (pull_request) Skipped
testCalibrationDashboardOpensAndCanGenerate failed in every full-suite
gate run while passing standalone: without an explicit activate() the
synthesized btnCalGenerate click was consumed by window activation when
focus sat on another app after the prior test app terminated. Matches
the launchApp() convention used by the other UI suites.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 15:15:16 +01:00
gronod f681e60778 Merge pull request 'refactor(ui): complete logged-run and Notice consolidation (#80)' (#103) from feat/80-process-run-notice-consolidation into milestone/m8-consolidation 2026-09-11 13:53:44 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0d0233e8d6 refactor(ui): complete logged-run and Notice consolidation (#80)
Refs #80

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 13:18:34 +01:00
gronod d117d7a510 Merge pull request 'test(runner): complete shared streaming loop contracts (#79)' (#102) from feat/79-runner-loop-contract-tests into milestone/m8-consolidation 2026-09-11 12:41:28 +01:00
51 changed files with 1193 additions and 1152 deletions
+10 -2
View File
@@ -14,13 +14,21 @@ 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-14 runs-on: macos-12
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)
- name: Ensure host tools
run: |
command -v xcodegen || brew install xcodegen
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
- name: Generate Xcode project - name: Generate Xcode project
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
@@ -120,7 +128,7 @@ jobs:
package: package:
needs: build-and-test needs: build-and-test
runs-on: macos-14 runs-on: macos-12
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
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "ICCeryCore", name: "ICCeryCore",
platforms: [.macOS(.v14)], platforms: [.macOS(.v12)],
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(for: .milliseconds(100)) try? await Task.sleep(nanoseconds: 100_000_000)
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(for: .milliseconds(500)) try? await Task.sleep(nanoseconds: 500_000_000)
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(for: .milliseconds(500)) try? await Task.sleep(nanoseconds: 500_000_000)
} }
} }
@@ -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(CGATSParseError) -> CGATSDataset { ) throws -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile } guard !contents.isEmpty else { throw CGATSParseError.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 .missingBeginDataFormat throw CGATSParseError.missingBeginDataFormat
} }
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else { guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData throw CGATSParseError.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 .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count) throw CGATSParseError.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 .nonNumericValue(field: name, value: raw, line: lineIndex + 1) throw CGATSParseError.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(CGATSParseError) -> (CGATSFormat, [String]) { ) throws -> (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 .emptyFile } guard !lines.isEmpty else { throw CGATSParseError.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(for: .seconds(2)) try? await Task.sleep(nanoseconds: 2_000_000_000)
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)
-7
View File
@@ -95,13 +95,6 @@ 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) .formStyle(.grouped)
+20 -23
View File
@@ -25,7 +25,6 @@ final class CalibrationViewModel {
var calibrationLog: [String] = [] var calibrationLog: [String] = []
var isGenerating = false var isGenerating = false
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
@@ -77,10 +76,6 @@ final class CalibrationViewModel {
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,
@@ -92,17 +87,19 @@ final class CalibrationViewModel {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isGenerating = false }
do { do {
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in _ = try await ProcessRunSupport.runLogged(
self?.calibrationLog.append(contentsOf: batch) setRunning: { self.isGenerating = $0 },
}) 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
@@ -137,15 +134,13 @@ final class CalibrationViewModel {
// "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) {
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first." wizard.showNotice(
wizard.showNotice(lastError!, kind: .error) "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
kind: .error
)
return return
} }
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: calBasename, ti3Basename: calBasename,
workingDirectory: cwd, workingDirectory: cwd,
@@ -158,19 +153,21 @@ final class CalibrationViewModel {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isComputing = false }
do { do {
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in let url = try await ProcessRunSupport.runLogged(
self?.calibrationLog.append(contentsOf: batch) setRunning: { self.isComputing = $0 },
}) 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
@@ -63,7 +63,8 @@ final class MeasurementWorkflowViewModel {
var rows: [ChartreadRow] = [] var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = [] var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false var showRemoveSheetNotice = false
var lastError: String? /// Stage-local chartread error notice (`#chartreadLastError`, #80).
var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>? private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging // MARK: - Averaging
@@ -185,7 +186,7 @@ final class MeasurementWorkflowViewModel {
isChartreadRunning = true isChartreadRunning = true
chartreadState = .idle chartreadState = .idle
currentPrompt = nil currentPrompt = nil
lastError = nil chartreadNotice = nil
chartreadLog.removeAll() chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass. // Optional: reset rows when starting a fresh first pass.
@@ -227,7 +228,10 @@ final class MeasurementWorkflowViewModel {
case .exit(let code): case .exit(let code):
if code != 0 { if code != 0 {
lastError = "chartread exited with code \(code)" chartreadNotice = Notice(
kind: .error,
text: "chartread exited with code \(code)"
)
} }
case .completed(let canonicalURL): case .completed(let canonicalURL):
@@ -235,7 +239,7 @@ final class MeasurementWorkflowViewModel {
completePass(canonicalURL: canonicalURL) completePass(canonicalURL: canonicalURL)
case .failed(let error): case .failed(let error):
lastError = error.localizedDescription chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
chartreadState = .error chartreadState = .error
isChartreadRunning = false isChartreadRunning = false
} }
@@ -381,7 +385,10 @@ final class MeasurementWorkflowViewModel {
discoverPassSnapshots() discoverPassSnapshots()
wizard.refreshGating() wizard.refreshGating()
} catch { } catch {
lastError = "Could not snapshot pass: \(error.localizedDescription)" chartreadNotice = Notice(
kind: .error,
text: "Could not snapshot pass: \(error.localizedDescription)"
)
} }
} }
@@ -397,30 +404,32 @@ final class MeasurementWorkflowViewModel {
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 {
let canonical: URL // No log reset: prior chartread output must be preserved.
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first { let canonical = try await ProcessRunSupport.runLogged(
canonical = try MeasurementArtefacts.promotePass( setRunning: { self.isFinishing = $0 },
pass: pass, resetLog: {},
basename: self.basename, onLog: { self.chartreadLog.append(contentsOf: $0) }
cwd: cwd ) { onLog in
) if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
} else { return try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
}
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
basename: self.basename, basename: self.basename,
passFiles: self.passSnapshots passFiles: self.passSnapshots
) )
canonical = try await self.environment.runner.runAverage( return try await self.environment.runner.runAverage(
config: config, config: config,
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in onLogBatch: onLog
self?.chartreadLog.append(contentsOf: batch)
}
) )
} }
self.discoverPassSnapshots() self.discoverPassSnapshots()
@@ -465,7 +474,6 @@ final class MeasurementWorkflowViewModel {
) )
} }
} }
self.isFinishing = false
} }
} }
} }
+8
View File
@@ -21,6 +21,14 @@ 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()
+53 -50
View File
@@ -31,7 +31,6 @@ final class ProfileWorkflowViewModel {
var isColprofRunning = false var isColprofRunning = false
var colprofLog: [String] = [] var colprofLog: [String] = []
var colprofProgress: String? var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL? 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).
var createdGamutURL: URL? var createdGamutURL: URL?
@@ -165,60 +164,59 @@ final class ProfileWorkflowViewModel {
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 url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in let outcome = try await ProcessRunSupport.runLogged(
guard let self else { return } setRunning: { self.isColprofRunning = $0 },
self.colprofLog.append(contentsOf: batch) resetLog: { self.colprofLog = [] },
if let last = batch.last { onLog: { batch in
self.updateProgress(ColprofProgressClassifier.classify(line: last)) self.colprofLog.append(contentsOf: batch)
if let last = batch.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
if self.applyCalibration, !self.calibrationFile.isEmpty { if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig( let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile, calibrationPath: self.calibrationFile,
inputProfileURL: url inputProfileURL: url
) )
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0") assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig) finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)") self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
var gamutURL: URL?
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
gamutURL = url
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
return (profileURL: finalProfileURL, gamutURL: gamutURL)
} }
self.createdProfileURL = outcome.profileURL
// Gamut extraction is best-effort for Stage 5 / M6 viewer. self.createdGamutURL = outcome.gamutURL
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.colprofLog.append(contentsOf: batch)
})
self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
self.createdProfileURL = finalProfileURL
self.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)") self.wizard.showNotice("Profile created: \(outcome.profileURL.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
@@ -285,23 +283,28 @@ final class ProfileWorkflowViewModel {
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 report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in let outcome = try await ProcessRunSupport.runLogged(
self?.colprofLog.append(contentsOf: batch) setRunning: { self.isProfcheckRunning = $0 },
}) resetLog: {},
self.profcheckReport = report onLog: { self.colprofLog.append(contentsOf: $0) }
if let record = self.makeVerificationRecord(from: report) { ) { onLog in
let updated = try await self.environment.historyStore.append(record) let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
self.verificationHistory = updated var history: [VerificationRecord]?
if let record = self.makeVerificationRecord(from: report) {
history = try await self.environment.historyStore.append(record)
}
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 {
+1 -1
View File
@@ -33,7 +33,7 @@ final class SettingsViewModel {
sink.applySettings(settings) sink.applySettings(settings)
savedFlash = true savedFlash = true
Task { Task {
try? await Task.sleep(for: .seconds(1.5)) try? await Task.sleep(nanoseconds: 1_500_000_000)
savedFlash = false savedFlash = false
} }
return true return true
+1
View File
@@ -218,6 +218,7 @@ 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
+11 -15
View File
@@ -164,28 +164,22 @@ struct Stage3View: View {
.foregroundStyle(Theme.accent) .foregroundStyle(Theme.accent)
} }
if let lastError = model.lastError { if let notice = model.chartreadNotice {
Text(lastError) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(.red) .foregroundStyle(notice.kind.tint)
.accessibilityIdentifier("chartreadLastError") .accessibilityIdentifier("chartreadLastError")
.accessibilityValue(lastError) .accessibilityValue(notice.text)
} }
controlButtons controlButtons
if !model.chartreadLog.isEmpty { if !model.chartreadLog.isEmpty {
DisclosureGroup("Log") { ProcessLogView(
VStack(alignment: .leading) { lines: model.chartreadLog,
ForEach(model.chartreadLog, id: \.self) { line in containerId: "chartreadLogContainer",
Text(line) logId: "chartreadLog"
.font(.system(.caption, design: .monospaced)) )
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
} }
} }
.padding(16) .padding(16)
@@ -374,6 +368,8 @@ 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)
+5 -18
View File
@@ -166,27 +166,14 @@ 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 {
DisclosureGroup("Log") { ProcessLogView(
VStack(alignment: .leading) { lines: model.colprofLog,
ForEach(model.colprofLog, id: \.self) { line in containerId: "colprofLogContainer",
Text(line) logId: "colprofLog"
.font(.system(.caption, design: .monospaced)) )
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
} }
} }
.padding(16) .padding(16)
+5 -5
View File
@@ -206,8 +206,6 @@ final class TargetWorkflowViewModel {
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
@@ -228,7 +226,6 @@ final class TargetWorkflowViewModel {
} catch { } catch {
wizard.showNotice( wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error) "targen failed: \(error.localizedDescription)", kind: .error)
targenRunning = false
} }
} }
} }
@@ -242,7 +239,12 @@ final class TargetWorkflowViewModel {
? 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 {
@@ -339,8 +341,6 @@ final class TargetWorkflowViewModel {
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
+1 -1
View File
@@ -201,7 +201,7 @@ final class WizardViewModel {
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(for: .seconds(delay)) try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
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
+13 -15
View File
@@ -1,27 +1,25 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("AppPaths") final class AppPathsTests: XCTestCase {
struct AppPathsTests { func testAppDataDirUsesBundleID() {
@Test func appDataDirUsesBundleID() { XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
} }
@Test func logFileIsUnderLibraryLogs() { func testLogFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log") XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2")) XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
} }
@Test func bundledArgyllDirIsInsideResources() { func testBundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll") XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
} }
} }
@Suite("WizardStage") final class WizardStageTests: XCTestCase {
struct WizardStageTests { func testStepperOrderIsOneThroughFive() {
@Test func stepperOrderIsOneThroughFive() { XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5]) XCTAssertNil(WizardStage.calibrate.stepperIndex)
#expect(WizardStage.calibrate.stepperIndex == nil)
} }
} }
@@ -1,22 +1,19 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ApplycalArgs") final class ApplycalArgsTests: XCTestCase {
struct ApplycalArgsTests {
@Test("Apply argv") func testApplyArgv() throws {
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)
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"]) XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
} }
@Test("Unapply is emitted when the caller explicitly sets it") func testUnapplyEmittedWhenConfigSet() throws {
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"),
@@ -25,6 +22,6 @@ struct ApplycalArgsTests {
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.
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"]) XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
} }
} }
+41 -55
View File
@@ -1,94 +1,80 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ArgsBuilder") final class ArgsBuilderTests: XCTestCase {
struct ArgsBuilderTests {
// MARK: - option // MARK: - option
@Test("option: nil emits nothing") func testOptionNil() {
func optionNil() { XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
#expect(ArgsBuilder.option("-f", nil) == [])
} }
@Test("option: present value emits flag and value verbatim") func testOptionPresent() {
func optionPresent() { XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
#expect(ArgsBuilder.option("-f", "abc") == ["-f", "abc"]) XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
#expect(ArgsBuilder.option("-f", "") == ["-f", ""]) XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
#expect(ArgsBuilder.option("-f", " padded ") == ["-f", " padded "])
} }
// MARK: - optionIfNonEmpty // MARK: - optionIfNonEmpty
@Test("optionIfNonEmpty: nil and empty emit nothing") func testOptionIfNonEmptyNilEmpty() {
func optionIfNonEmptyNilEmpty() { XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", nil) == []) XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "") == [])
} }
@Test("optionIfNonEmpty: whitespace-only emits nothing") func testOptionIfNonEmptyWhitespace() {
func optionIfNonEmptyWhitespace() { XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " ") == []) XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " \t\n ") == [])
} }
@Test("optionIfNonEmpty: trims surrounding whitespace") func testOptionIfNonEmptyTrims() {
func optionIfNonEmptyTrims() { XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " label ") == ["-d", "label"]) XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n") == ["-d", "cal.cal"])
} }
// MARK: - optionUnlessApprox // MARK: - optionUnlessApprox
@Test("optionUnlessApprox: nil emits nothing") func testOptionUnlessApproxNil() {
func optionUnlessApproxNil() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
#expect(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50) == [])
} }
@Test("optionUnlessApprox: exact skip value emits nothing") func testOptionUnlessApproxExactSkip() {
func optionUnlessApproxExactSkip() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50) == []) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0) == [])
} }
@Test("optionUnlessApprox: within epsilon emits nothing") func testOptionUnlessApproxWithinEpsilon() {
func optionUnlessApproxWithinEpsilon() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50) == []) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0) == [])
} }
@Test("optionUnlessApprox: outside epsilon emits flag") func testOptionUnlessApproxOutsideEpsilon() {
func optionUnlessApproxOutsideEpsilon() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50) == ["-N", "0.75"]) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0) == ["-V", "1.50"]) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50) == ["-N", "0.50"])
} }
@Test("optionUnlessApprox: POSIX formatting is locale-stable") func testOptionUnlessApproxPOSIX() {
func optionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal. // 1234.5 must never produce a grouping separator or comma decimal.
#expect(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0) == ["-p", "1234.50"]) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"])
#expect(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0) == ["-p", "2.00"]) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
} }
@Test("optionUnlessApprox: custom epsilon and format honoured") func testOptionUnlessApproxCustom() {
func optionUnlessApproxCustom() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
#expect(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"])
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f") == ["-x", "1.5"])
} }
// MARK: - flag // MARK: - flag
@Test("flag: true emits the bare flag") func testFlagTrue() {
func flagTrue() { XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
#expect(ArgsBuilder.flag("-G", when: true) == ["-G"]) XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
#expect(ArgsBuilder.flag("-r", when: true) == ["-r"])
} }
@Test("flag: false emits nothing") func testFlagFalse() {
func flagFalse() { XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
#expect(ArgsBuilder.flag("-G", when: false) == []) XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
#expect(ArgsBuilder.flag("-r", when: false) == [])
} }
} }
+46 -60
View File
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
import ImageIO import ImageIO
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL {
.appendingPathComponent(name) .appendingPathComponent(name)
} }
@Suite("Ti2Header") final class Ti2HeaderTests: XCTestCase {
struct Ti2HeaderTests { func testParsesKeywordsAndSibling() throws {
@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)
@@ -31,18 +30,18 @@ struct Ti2HeaderTests {
) )
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2")) let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO") XCTAssertEqual(h.instrument, "i1iO")
#expect(h.patchCount == 800) XCTAssertEqual(h.patchCount, 800)
#expect(h.pageCount == 3) XCTAssertEqual(h.pageCount, 3)
#expect(h.hasSiblingTi1) XCTAssertTrue(h.hasSiblingTi1)
} }
@Test func missingFileYieldsEmptyHeader() { func testMissingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2")) let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1) XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
} }
@Test func numberOfFieldsIsNotPatchCount() throws { func testNumberOfFieldsIsNotPatchCount() 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
@@ -50,12 +49,11 @@ struct Ti2HeaderTests {
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
) )
#expect(Ti2Header.parse(url).patchCount == 52) XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
} }
} }
@Suite("TiffPreview") final class TiffPreviewTests: XCTestCase {
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")
@@ -81,50 +79,48 @@ struct TiffPreviewTests {
return url return url
} }
@Test func producesCappedPNG() throws { func testProducesCappedPNG() throws {
let tiff = try makeTiff() let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff) let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil) XCTAssertNotNil(png)
// PNG magic // PNG magic
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) XCTAssertEqual(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)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge) XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200) XCTAssertEqual(img.width, 1200)
} }
@Test func nonTiffReturnsNil() throws { func testNonTiffReturnsNil() 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)
#expect(TiffPreview.previewPNG(tiff: url) == nil) XCTAssertNil(TiffPreview.previewPNG(tiff: url))
} }
} }
@Suite("ArtefactFiles") final class ArtefactFilesTests: XCTestCase {
struct ArtefactFilesTests { func testBase64RoundTrip() throws {
@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)
#expect(Data(base64Encoded: b64) == Data("hello".utf8)) XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
} }
@Test func defaultWorkingDirExists() { func testDefaultWorkingDirExists() {
#expect(FileManager.default.fileExists( XCTAssertTrue(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path atPath: ArtefactFiles.defaultWorkingDirectory().path
)) ))
} }
} }
@Suite("ArtefactProbe profile resolve") final class ArtefactProbeProfileTests: XCTestCase {
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)")
@@ -134,93 +130,83 @@ struct ArtefactProbeProfileTests {
// MARK: Basename probe matrix (#69) // MARK: Basename probe matrix (#69)
@Test("basename probe: only .icc exists") func testOnlyIcc() throws {
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)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path) XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path)
} }
@Test("basename probe: only .icm exists") func testOnlyIcm() throws {
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)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path) XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path)
} }
@Test("basename probe prefers .icm") func testIcmWins() throws {
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)
#expect(url?.path == icm.path) XCTAssertEqual(url?.path, icm.path)
} }
@Test("basename probe: neither exists returns nil") func testNeitherExists() throws {
func neitherExists() throws {
let dir = try makeDir() let dir = try makeDir()
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil) XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir))
} }
// MARK: Explicit URL matrix (#69 / #83) // MARK: Explicit URL matrix (#69 / #83)
@Test("explicit existing .icc wins even when .icm exists") func testExplicitIccWins() throws {
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"))
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path) XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
} }
@Test("explicit existing .icm wins even when .icc exists") func testExplicitIcmWins() throws {
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)
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path) XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path)
} }
@Test("explicit missing .icc flips to sibling .icm") func testFlipExtension() throws {
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)
#expect(resolved.path == icm.path) XCTAssertEqual(resolved.path, icm.path)
} }
@Test("explicit missing .icm flips to sibling .icc") func testFlipToIcc() throws {
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)
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path) XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path)
} }
@Test("explicit missing both returns the original URL") func testMissingBoth() throws {
func missingBoth() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path) XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
} }
@Test("unrelated extension is never rewritten") func testUnrelatedExtension() throws {
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.
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path) XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path)
let txt = dir.appendingPathComponent("job.txt") let txt = dir.appendingPathComponent("job.txt")
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path) XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path)
} }
} }
+16 -17
View File
@@ -1,9 +1,8 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("BinaryResolver") final class BinaryResolverTests: XCTestCase {
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
@@ -22,16 +21,16 @@ struct BinaryResolverTests {
} }
} }
@Test func overrideDirWinsWhenFileExists() throws { func testOverrideDirWinsWhenFileExists() 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)
#expect(r.resolve("targen") == override.appendingPathComponent("targen")) XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen"))
} }
@Test func overrideFallsThroughWhenMissing() throws { func testOverrideFallsThroughWhenMissing() 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")
@@ -39,10 +38,10 @@ struct BinaryResolverTests {
try touch(dir.appendingPathComponent("instlist")) try touch(dir.appendingPathComponent("instlist"))
} }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen").path.contains("macos-universal/targen")) XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen"))
} }
@Test func universalPreferredWhenMarkerPresent() throws { func testUniversalPreferredWhenMarkerPresent() 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)
@@ -51,10 +50,10 @@ struct BinaryResolverTests {
} }
} }
let r = BinaryResolver(bundledRoot: bundled) let r = BinaryResolver(bundledRoot: bundled)
#expect(r.platformDir() == "macos-universal") XCTAssertEqual(r.platformDir(), "macos-universal")
} }
@Test func fallsBackToArchDir() throws { func testFallsBackToArchDir() 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)
@@ -64,20 +63,20 @@ struct BinaryResolverTests {
bundledRoot: bundled, bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"] archDirs: ["macos-universal", "macos-x86_64"]
) )
#expect(r.platformDir() == "macos-x86_64") XCTAssertEqual(r.platformDir(), "macos-x86_64")
} }
@Test func missingEverythingReturnsConstructedPath() throws { func testMissingEverythingReturnsConstructedPath() 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.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen")) XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen"))) XCTAssertFalse(r.exists(r.resolve("targen")))
} }
@Test func mockAndGamutPaths() throws { func testMockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x")) let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock") XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam") XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
} }
} }
+37 -50
View File
@@ -1,9 +1,8 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CGATS Parser & Writer") final class CGATSParserTests: XCTestCase {
struct CGATSParserTests {
private static let canonicalCTI3 = """ private static let canonicalCTI3 = """
CTI3 CTI3
@@ -21,44 +20,40 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
@Test("Parses CTI3 with canonical field names") func testParseCTI3() throws {
func parseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3) let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3) XCTAssertEqual(dataset.format, .cti3)
#expect(dataset.samples.count == 2) XCTAssertEqual(dataset.samples.count, 2)
#expect(dataset.colorRep == "RGB") XCTAssertEqual(dataset.colorRep, "RGB")
#expect(dataset.deviceClass == "DISPLAY") XCTAssertEqual(dataset.deviceClass, "DISPLAY")
#expect(dataset.samples[0].id == "1") XCTAssertEqual(dataset.samples[0].id, "1")
#expect(dataset.samples[0].loc == "A1") XCTAssertEqual(dataset.samples[0].loc, "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000") XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
} }
@Test("Round-trips parse, write, reparse") func testRoundTrip() throws {
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)
#expect(second.format == first.format) XCTAssertEqual(second.format, first.format)
#expect(second.samples.count == first.samples.count) XCTAssertEqual(second.samples.count, first.samples.count)
#expect(second.colorRep == first.colorRep) XCTAssertEqual(second.colorRep, first.colorRep)
#expect(second.deviceClass == first.deviceClass) XCTAssertEqual(second.deviceClass, first.deviceClass)
} }
@Test("Parses CSV with comma delimiters") func testParseCSV() throws {
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"))
#expect(dataset.format == .csv) XCTAssertEqual(dataset.format, .csv)
#expect(dataset.samples.count == 2) XCTAssertEqual(dataset.samples.count, 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000") XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
} }
@Test("Converts 0-255 device values to 0-100") func testConverts255To100() throws {
func converts255To100() throws {
let rgb = """ let rgb = """
CTI3 CTI3
COLOR_REP RGB COLOR_REP RGB
@@ -72,12 +67,11 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
let dataset = try CGATSParser.parse(rgb) let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000") XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961") XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
} }
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing") func testSynthesizesMetadata() throws {
func synthesizesMetadata() throws {
let cmyk = """ let cmyk = """
CTI3 CTI3
NUMBER_OF_FIELDS 6 NUMBER_OF_FIELDS 6
@@ -90,19 +84,15 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
let dataset = try CGATSParser.parse(cmyk) let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK") XCTAssertEqual(dataset.colorRep, "CMYK")
#expect(dataset.deviceClass == "PRINTER") XCTAssertEqual(dataset.deviceClass, "PRINTER")
} }
@Test("Rejects empty file") func testRejectsEmpty() {
func rejectsEmpty() { XCTAssertThrowsError(try CGATSParser.parse(""))
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
} }
@Test("Rejects malformed arity") func testRejectsArity() {
func rejectsArity() {
let bad = """ let bad = """
CTI3 CTI3
NUMBER_OF_FIELDS 2 NUMBER_OF_FIELDS 2
@@ -114,21 +104,18 @@ struct CGATSParserTests {
1 1
END_DATA END_DATA
""" """
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CGATSParser.parse(bad))
_ = try CGATSParser.parse(bad)
}
} }
@Test("Writer emits valid .ti3 with tabs and required keywords") func testWriterFormat() throws {
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)
#expect(text.contains("CTI3")) XCTAssertTrue(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT")) XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA")) XCTAssertTrue(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA")) XCTAssertTrue(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP")) XCTAssertTrue(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS")) XCTAssertTrue(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t")) XCTAssertTrue(text.contains("\t"))
} }
} }
@@ -1,78 +1,67 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// Issue #83 canonical `CAL_` / original-stem pairing. /// Issue #83 canonical `CAL_` / original-stem pairing.
@Suite("CalibrationIdentity") final class CalibrationIdentityTests: XCTestCase {
struct CalibrationIdentityTests { func testLivePlain() {
@Test("live foo, no persisted")
func livePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
#expect(id.originalBasename == "foo") XCTAssertEqual(id.originalBasename, "foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("live foo ignores stale persisted") func testLivePlainIgnoresPersisted() {
func livePlainIgnoresPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar") let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
#expect(id.originalBasename == "foo") XCTAssertEqual(id.originalBasename, "foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("live CAL_foo, persisted foo") func testLiveCalPersisted() {
func liveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
#expect(id.originalBasename == "foo") XCTAssertEqual(id.originalBasename, "foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("live CAL_foo, empty persisted strips prefix") func testLiveCalNoPersist() {
func liveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "foo") XCTAssertEqual(id.originalBasename, "foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("persisted original wins over CAL_ live") func testPersistedWins() {
func persistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
#expect(id.originalBasename == "bar") XCTAssertEqual(id.originalBasename, "bar")
#expect(id.calibrationBasename == "CAL_bar") XCTAssertEqual(id.calibrationBasename, "CAL_bar")
} }
@Test("empty live yields empty identity even with persisted original") func testEmptyLiveWithPersisted() {
func emptyLiveWithPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo") let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
#expect(id.originalBasename.isEmpty) XCTAssertTrue(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty) XCTAssertTrue(id.calibrationBasename.isEmpty)
} }
@Test("empty live, empty persisted") func testEmptyLive() {
func emptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
#expect(id.originalBasename.isEmpty) XCTAssertTrue(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty) XCTAssertTrue(id.calibrationBasename.isEmpty)
} }
@Test("prefix is idempotent on already-prefixed input") func testAlreadyPrefixed() {
func alreadyPrefixed() { XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo") XCTAssertEqual(CalibrationIdentity.prefix("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: "")
#expect(id.originalBasename == "CAL_foo") XCTAssertEqual(id.originalBasename, "CAL_foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("prefix never invents a name from empty input") func testPrefixEmpty() {
func prefixEmpty() { XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
#expect(CalibrationIdentity.prefix("").isEmpty) XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
#expect(CalibrationIdentity.strip("foo") == "foo") XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
} }
@Test("runner process id for a calibration targen is targen_CAL_*") func testProcessIdMatches() {
func processIdMatches() {
let cal = CalibrationIdentity.prefix("foo") let cal = CalibrationIdentity.prefix("foo")
#expect(ProcessID.targen(cal) == "targen_CAL_foo") XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
} }
} }
@@ -1,12 +1,10 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CalibrationTargenArgs") final class CalibrationTargenArgsTests: XCTestCase {
struct CalibrationTargenArgsTests {
@Test("RGB baseline") func testRgbBaseline() throws {
func rgbBaseline() throws {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
steps: 21, steps: 21,
@@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp") workingDirectory: URL(fileURLWithPath: "/tmp")
) )
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"]) XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
} }
@Test("CMYK baseline with ink limit and neutral emphasis") func testCmykWithOptions() throws {
func cmykWithOptions() throws {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
steps: 25, steps: 25,
@@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp") workingDirectory: URL(fileURLWithPath: "/tmp")
) )
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"]) XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
} }
@Test("Rejects out-of-range steps") func testRejectsBadSteps() {
func rejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo") let config = CalibrationTargenConfig(steps: 5, basename: "demo")
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
_ = try CalibrationTargenArgs.build(config: config)
}
} }
@Test("Rejects bad CMYK ink limit") func testRejectsBadInkLimit() {
func rejectsBadInkLimit() {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
inkLimit: 500, inkLimit: 500,
basename: "demo" basename: "demo"
) )
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
_ = try CalibrationTargenArgs.build(config: config)
}
} }
@Test("Does not double-prefix an existing CAL_ basename") func testNoDoublePrefix() throws {
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)
#expect(args.last == "CAL_test") XCTAssertEqual(args.last, "CAL_test")
} }
} }
+23 -32
View File
@@ -1,72 +1,63 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ColprofArgs") final class ColprofArgsTests: XCTestCase {
struct ColprofArgsTests {
@Test("Default algorithm and quality") func testDefaults() throws {
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)
#expect(args == ["-v", "-a", "l", "-q", "m", "target"]) XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"])
} }
@Test("FWA bare -f when empty string") func testFwaBareFlag() throws {
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)
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"]) XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"])
} }
@Test("FWA D50 and D65 emit -f value") func testFwaD50() throws {
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)
#expect(args.contains("-f")) XCTAssertTrue(args.contains("-f"))
#expect(args.contains("D50")) XCTAssertTrue(args.contains("D50"))
#expect(args.last == "target") XCTAssertEqual(args.last, "target")
} }
@Test("FWA none is omitted") func testFwaNoneOmitted() throws {
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)
#expect(!args.contains("-f")) XCTAssertFalse(args.contains("-f"))
} }
@Test("Viewing conditions skip none") func testViewingCondNoneSkipped() throws {
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)
#expect(!args.contains("-c")) XCTAssertFalse(args.contains("-c"))
#expect(args.contains("-d")) XCTAssertTrue(args.contains("-d"))
#expect(args.contains("mt")) XCTAssertTrue(args.contains("mt"))
} }
@Test("Description falls back to basename when empty") func testDescriptionFallback() throws {
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)
#expect(!args.contains("-D")) XCTAssertFalse(args.contains("-D"))
} }
@Test("Copyright only when non-empty") func testCopyright() throws {
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)
#expect(args.contains("-C")) XCTAssertTrue(args.contains("-C"))
#expect(args.contains("Gronod 2026")) XCTAssertTrue(args.contains("Gronod 2026"))
} }
@Test("No -u passed") func testNoProgressJsonFlag() throws {
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)
#expect(!args.contains("-u")) XCTAssertFalse(args.contains("-u"))
} }
} }
@@ -1,24 +1,20 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ColprofProgress") final class ColprofProgressTests: XCTestCase {
struct ColprofProgressTests {
@Test("Classifies gamut mapping") func testGamutMapping() {
func gamutMapping() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
} }
@Test("Classifies fitting or clut") func testFitting() {
func fitting() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut) XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
} }
@Test("Classifies writing") func testWriting() {
func writing() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc) XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
} }
} }
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
@@ -6,48 +6,42 @@ import AppKit
import ApplicationServices import ApplicationServices
/// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ). /// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ).
@Suite("CupsOptionsFilter") final class CupsOptionsFilterTests: XCTestCase {
struct CupsOptionsFilterTests {
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys") func testDropsReserved() {
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"
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy") XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
} }
@Test("Keeps relevant driver keys, order preserved") func testKeepsRelevant() {
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"
#expect(CupsOptionsFilter.filter(raw) == raw) XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
} }
@Test("Permissive: unknown non-com.* keys survive") func testKeepsUnknown() {
func keepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain" let raw = "VendorFooBar=baz MediaType=Plain"
#expect(CupsOptionsFilter.filter(raw) == raw) XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
} }
@Test("Drops empty keys and values") func testDropsEmpty() {
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.
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear") XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear")
} }
@Test("extractMediaType prefers MediaType then EPIJ_Medi") func testExtractMedia() {
func extractMedia() { XCTAssertEqual(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo") XCTAssertEqual(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "EPIJ_Medi=7"), "7")
fromOptionsString: "EPIJ_Medi=7") == "7") XCTAssertNil(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "PageSize=A4"))
fromOptionsString: "PageSize=A4") == nil)
} }
} }
@@ -55,9 +49,8 @@ struct CupsOptionsFilterTests {
/// `@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
struct ColorSyncSuppressorTests { final class ColorSyncSuppressorTests: XCTestCase {
/// Fake PMPrintSession the injected resolver never dereferences it. /// Fake PMPrintSession the injected resolver never dereferences it.
private var fakeSession: PMPrintSession { private var fakeSession: PMPrintSession {
@@ -90,55 +83,50 @@ struct ColorSyncSuppressorTests {
return s return s
} }
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first") func testAttemptOrder() {
func attemptOrder() {
Self.recorded = [] Self.recorded = []
Self.succeeding = nil Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"] Self.missing = ["PMSessionSetColorMatchingModeLock"]
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false) XCTAssertEqual(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.
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
== ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" } .filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" }) .map { "\($0.symbol)|\($0.mode)" })
} }
@Test("First zero wins — later symbols/modes not called") func testFirstZeroWins() {
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()
#expect(s.applySPIMode(to: fakeSession)) XCTAssertTrue(s.applySPIMode(to: fakeSession))
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [ XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
]) ])
} }
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried") func testModeFallback() {
func modeFallback() {
Self.recorded = [] Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock", Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching") "ApplicationColorMatching")
Self.missing = [] Self.missing = []
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession)) XCTAssertTrue(s.applySPIMode(to: fakeSession))
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock") XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching") XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock") XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[1].1 == "ApplicationColorMatching") XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
#expect(Self.recorded.count == 2) XCTAssertEqual(Self.recorded.count, 2)
} }
@Test("All symbols missing → false, no calls") func testAllMissing() {
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()
#expect(s.applySPIMode(to: fakeSession) == false) XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
#expect(Self.recorded.isEmpty) XCTAssertTrue(Self.recorded.isEmpty)
} }
} }
+51 -62
View File
@@ -1,11 +1,10 @@
import Testing import XCTest
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.
@Suite("CupsParsers") final class CupsParsersTests: XCTestCase {
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 = """
@@ -34,112 +33,102 @@ struct CupsParsersTests {
cupsPrintQuality/cupsPrintQuality: Draft *Normal High cupsPrintQuality/cupsPrintQuality: Draft *Normal High
""" """
@Test("lpstat -e: one destination per line; empty = success") func testDestinations() {
func destinations() { XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
#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",
]) ])
#expect(CupsParsers.lpstatDestinations("") == []) XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
} }
@Test("lpstat -p: idle / now-printing / disabled statuses") func testStatuses() {
func statuses() {
let s = CupsParsers.lpstatStatuses(lpstatP) let s = CupsParsers.lpstatStatuses(lpstatP)
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle) XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
#expect(s["Epson_XP_55_LPD"] == .printing) XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
#expect(s["EPSON_XP_55_Series"] == .stopped) XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
} }
@Test("lpstat -d: default destination or none") func testDefaultDestination() {
func defaultDestination() { XCTAssertEqual(CupsParsers.lpstatDefault(
#expect(CupsParsers.lpstatDefault( "system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
"system default destination: Canon_Pro9500_II_series_XPS\n") XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
} }
@Test("lpoptions -p: quoted printer-info, bare flags ignored") func testDisplayName() {
func displayName() { XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series") XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
} }
@Test("lpoptions -l: key/label split, * marks the default") func testOptionListings() {
func optionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL) let listings = CupsParsers.lpoptionsList(lpoptionsL)
#expect(listings.count == 6) XCTAssertEqual(listings.count, 6)
let page = listings[0] let page = listings[0]
#expect(page.key == "PageSize") XCTAssertEqual(page.key, "PageSize")
#expect(page.label == "Media Size") XCTAssertEqual(page.label, "Media Size")
#expect(page.defaultChoice == "A4") XCTAssertEqual(page.defaultChoice, "A4")
#expect(page.choices.contains("Custom.WIDTHxHEIGHT")) XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
#expect(!page.choices.contains("*A4")) XCTAssertFalse(page.choices.contains("*A4"))
let slot = listings[1] let slot = listings[1]
#expect(slot.key == "InputSlot") XCTAssertEqual(slot.key, "InputSlot")
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"]) XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
#expect(slot.defaultChoice == "Main") XCTAssertEqual(slot.defaultChoice, "Main")
} }
@Test("capabilities: trays/sizes index 1-based, media uses detected key") func testCapabilities() {
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)
#expect(caps.trays == [ XCTAssertEqual(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"),
]) ])
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5")) XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
#expect(caps.paperSizes.count == 10) XCTAssertEqual(caps.paperSizes.count, 10)
#expect(caps.mediaTypes.map(\.id) == [ XCTAssertEqual(caps.mediaTypes.map(\.id), [
"Stationery", "PhotographicHighGloss", "Photographic", "Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope", "PhotographicMatte", "Envelope",
]) ])
#expect(caps.supportsOrientation) XCTAssertTrue(caps.supportsOrientation)
} }
@Test("PPD enrichment maps id → human label") func testPpdLabels() {
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")
#expect(labels["42"] == "Photo Paper Plus Semi-gloss") XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
#expect(labels["0"] == "Plain Paper") XCTAssertEqual(labels["0"], "Plain Paper")
#expect(labels["13"] == "Envelope") XCTAssertEqual(labels["13"], "Envelope")
} }
@Test("detectMediaTypeKey prefers vendor keys in order") func testMediaTypeKey() {
func mediaTypeKey() { XCTAssertEqual(CupsParsers.detectMediaTypeKey(
#expect(CupsParsers.detectMediaTypeKey( optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType") XCTAssertEqual(CupsParsers.detectMediaTypeKey(
#expect(CupsParsers.detectMediaTypeKey( optionKeys: ["PageSize", "MediaType"]), "MediaType")
optionKeys: ["PageSize", "MediaType"]) == "MediaType") XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
} }
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat") func testDriverBypass() {
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)" }
} }
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4") XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4") XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0") XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3") XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected") XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected") XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off") XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
#expect(pair(["PageSize"]) == nil) XCTAssertNil(pair(["PageSize"]))
} }
} }
+20 -30
View File
@@ -1,65 +1,57 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("DriftAlert") final class DriftAlertTests: XCTestCase {
struct DriftAlertTests {
@Test("No alert with fewer than two poor results") func testNotEnough() {
func notEnough() {
let records = [ let records = [
record(avg: 4.0, at: 1000) record(avg: 4.0, at: 1000)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Alert on two poor results one hour apart") func testOneHourApart() {
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)
] ]
#expect(DriftAlert.compute(from: records) != nil) XCTAssertNotNil(DriftAlert.compute(from: records))
} }
@Test("No alert if same day and under one hour") func testSameDayUnderHour() {
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)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Alert on distinct days") func testDistinctDays() {
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)
#expect(DriftAlert.compute(from: [day1, day2]) != nil) XCTAssertNotNil(DriftAlert.compute(from: [day1, day2]))
} }
@Test("Non-poor records do not trigger") func testNonPoor() {
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)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Non-poor records break the consecutive poor run") func testNonPoorBreaksRun() {
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
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Only the final consecutive poor run is considered") func testOnlySuffixRun() {
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
@@ -67,26 +59,24 @@ struct DriftAlertTests {
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
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Final consecutive poor run alerts when far apart") func testSuffixRunAlerts() {
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
] ]
#expect(DriftAlert.compute(from: records) != nil) XCTAssertNotNil(DriftAlert.compute(from: records))
} }
@Test("A single final poor record after good records does not alert") func testSingleFinalPoor() {
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)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord { private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
+35 -38
View File
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -13,91 +13,88 @@ 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)
} }
@Suite("PathSecurity") final class PathSecurityTests: XCTestCase {
struct PathSecurityTests { func testRejectsTraversalAndSeparators() {
@Test func rejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] { for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad)) XCTAssertFalse(PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) { XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
try PathSecurity.sanitizeBasename(bad) XCTAssertTrue(error is PathSecurity.Error)
} }
} }
} }
@Test func acceptsNormalNames() { func testAcceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] { for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
#expect(PathSecurity.isValidBasename(good)) XCTAssertTrue(PathSecurity.isValidBasename(good))
} }
} }
@Test func resolveSafeCwdPrefersExplicit() throws { func testResolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir() let dir = try tempDir()
#expect(PathSecurity.resolveSafeCwd(dir) == dir) XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
} }
@Test func resolveSafeCwdNeverReturnsNil() { func testResolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)") let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing) let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
} }
} }
@Suite("AtomicFileWriter") final class AtomicFileWriterTests: XCTestCase {
struct AtomicFileWriterTests { func testWritesAndLeavesNoTmp() throws {
@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)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}") XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path)) XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
} }
@Test func overwritesExistingAtomically() throws { func testOverwritesExistingAtomically() 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)
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer") XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer")
} }
@Test func createsParentDirs() throws { func testCreatesParentDirs() 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)
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
} }
} }
@Suite("ArtefactProbe") final class ArtefactProbeTests: XCTestCase {
struct ArtefactProbeTests { func testVerifyProgression() throws {
@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)
#expect(v == StageArtefacts()) XCTAssertEqual(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)
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete) XCTAssertTrue(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)
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete) XCTAssertTrue(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)
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc") XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc")
} }
@Test func icmWinsOverIcc() throws { func testIcmWinsOverIcc() 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)
#expect(profile?.pathExtension == "icm") XCTAssertEqual(profile?.pathExtension, "icm")
} }
@Test func enumeratesPassesPagesAndCAL() throws { func testEnumeratesPassesPagesAndCAL() 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",
@@ -115,15 +112,15 @@ struct ArtefactProbeTests {
"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",
] { ] {
#expect(names.contains(expected), "missing \(expected)") XCTAssertTrue(names.contains(expected), "missing \(expected)")
} }
#expect(!names.contains("other.ti1")) XCTAssertFalse(names.contains("other.ti1"))
#expect(!names.contains("t.txt")) XCTAssertFalse(names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1")) XCTAssertFalse(names.contains("CAL_other.ti1"))
} }
@Test func emptyDirReturnsEmpty() throws { func testEmptyDirReturnsEmpty() throws {
let dir = try tempDir() let dir = try tempDir()
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty) XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
} }
} }
@@ -1,15 +1,13 @@
import Testing import XCTest
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
struct GamutGeometryBuilderTests { final class GamutGeometryBuilderTests: XCTestCase {
@Test("Drops out-of-bounds faces from the element without crashing") func testDropsOutOfBoundsFaces() {
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)
@@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests {
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh) let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer") XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer")
} }
} }
@@ -1,10 +1,9 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests. /// ``GamutMeshParser`` acceptance + edge-case tests.
@Suite("Gamut mesh parser") final class GamutMeshParserTests: XCTestCase {
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 {
@@ -13,16 +12,14 @@ struct GamutMeshParserTests {
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
} }
@Test("Parses bundled sRGB.gam") func testParsesBundledSRGB() throws {
func parsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL) let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices") XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces") XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
} }
@Test("Discards VERTEX_NO and uses push-order indices") func testDiscardsVertexNo() throws {
func discardsVertexNo() throws {
let text = """ let text = """
GAMUT GAMUT
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
@@ -49,14 +46,13 @@ struct GamutMeshParserTests {
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 4) XCTAssertEqual(mesh.vertices.count, 4)
#expect(mesh.faces.count == 2) XCTAssertEqual(mesh.faces.count, 2)
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30)) XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30))
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60)) XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60))
} }
@Test("Ignores comments and blank lines") func testIgnoresComments() throws {
func ignoresComments() throws {
let text = """ let text = """
# Header comment # Header comment
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
@@ -81,12 +77,11 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 2) XCTAssertEqual(mesh.vertices.count, 2)
#expect(mesh.faces.count == 1) XCTAssertEqual(mesh.faces.count, 1)
} }
@Test("Remaps coordinates to x=a*, y=L*, z=b*") func testRemapsCoordinates() throws {
func remapsCoordinates() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -99,11 +94,10 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80)) XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80))
} }
@Test("Computes per-vertex sRGB colour") func testComputesVertexColor() throws {
func computesVertexColor() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -116,14 +110,13 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
let white = try #require(mesh.vertices.first).rgb let white = try XCTUnwrap(mesh.vertices.first).rgb
#expect(white.r > 0.95) XCTAssertTrue(white.r > 0.95)
#expect(white.g > 0.95) XCTAssertTrue(white.g > 0.95)
#expect(white.b > 0.95) XCTAssertTrue(white.b > 0.95)
} }
@Test("Drops out-of-bounds face indices") func testDropsOutOfBoundsFaces() throws {
func dropsOutOfBoundsFaces() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -146,21 +139,23 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.faces.count == 1) XCTAssertEqual(mesh.faces.count, 1)
} }
@Test("Throws on empty file") func testThrowsOnEmptyFile() {
func throwsOnEmptyFile() { XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
#expect(throws: GamutMeshParseError.noDataBlock) { guard case GamutMeshParseError.noDataBlock = error else {
_ = try GamutMeshParser.parse(text: "") return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
}
} }
} }
@Test("Throws when file is missing") func testThrowsWhenMissing() {
func throwsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam") let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
#expect(throws: GamutMeshParseError.missingFile) { XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
_ = try GamutMeshParser.parse(url: url) guard case GamutMeshParseError.missingFile = error else {
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
}
} }
} }
} }
@@ -1,16 +1,14 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("IccgamutArgs") final class IccgamutArgsTests: XCTestCase {
struct IccgamutArgsTests {
@Test("Density is 10 and not a directory") func testDensityNotDirectory() throws {
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)
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"]) XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
} }
} }
+44 -57
View File
@@ -1,12 +1,11 @@
import Testing import XCTest
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.
@Suite("LpArgs") final class LpArgsTests: XCTestCase {
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"
@@ -20,112 +19,100 @@ struct LpArgsTests {
options: options, optionKeys: optionKeys) options: options, optionKeys: optionKeys)
} }
@Test("Header: -d queue -t title, both AP_* first, TIFF last") func testHeader() throws {
func header() throws {
let argv = try build() let argv = try build()
#expect(Array(argv[0...1]) == ["-d", queue]) XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"]) XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
#expect(Array(argv[4...5]) XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(Array(argv[6...7]) XCTAssertEqual(argv.last, tiff)
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
} }
@Test("Never emits -o raw; captured raw= is dropped") func testNeverRaw() throws {
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" {
#expect(argv[i + 1] != "raw") XCTAssertNotEqual(argv[i + 1], "raw")
#expect(argv[i + 1] != "raw=true") XCTAssertNotEqual(argv[i + 1], "raw=true")
} }
#expect(!argv.contains { $0.hasPrefix("raw=") }) XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
#expect(argv.contains("MediaType=Photo")) XCTAssertTrue(argv.contains("MediaType=Photo"))
} }
@Test("Captured options replayed after AP_* headers") func testCapturedReplay() throws {
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")!
#expect(rear > apFirst) XCTAssertTrue(rear > apFirst)
} }
@Test("Captured wins: media key present → derived media skipped") func testCapturedWinsMedia() throws {
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"])
#expect(argv.contains("MediaType=Glossy")) XCTAssertTrue(argv.contains("MediaType=Glossy"))
#expect(!argv.contains("MediaType=Plain")) XCTAssertFalse(argv.contains("MediaType=Plain"))
} }
@Test("Media emitted via detected key when not captured") func testMediaDerived() throws {
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.
#expect(argv.contains("CNIJMediaType=SemiGloss")) XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
#expect(!argv.contains("MediaType=SemiGloss")) XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
} }
@Test("Driver bypass emitted when absent, skipped when captured") func testBypassRules() throws {
func bypassRules() throws {
let withBypass = try build( let withBypass = try build(
optionKeys: ["EPIJ_CMat"]) optionKeys: ["EPIJ_CMat"])
#expect(withBypass.contains("EPIJ_CMat=3")) XCTAssertTrue(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.
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") } XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
== ["EPIJ_CMat=1"])
} }
@Test("Orientation: portrait=3 landscape=4; captured wins") func testOrientation() throws {
func orientation() throws { XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
#expect(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3")) .contains("orientation-requested=3"))
#expect(try build(options: PrintOptions(orientation: "landscape")) XCTAssertTrue(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"))
#expect(!capturedOrients.contains("orientation-requested=4")) XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
#expect(capturedOrients.contains("orientation-requested=5")) XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
} }
@Test("PageSize emitted unless captured") func testPageSize() throws {
func pageSize() throws { XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
#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"))
#expect(!capturedSize.contains("PageSize=A4")) XCTAssertFalse(capturedSize.contains("PageSize=A4"))
#expect(capturedSize.contains("PageSize=Letter")) XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
} }
@Test("Sanitise rejects `;`, newline, and shell metachars") func testSanitise() throws {
func sanitise() throws { XCTAssertThrowsError(try build(options: PrintOptions(
#expect(throws: LpArgsError.self) { cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
_ = try build(options: PrintOptions( XCTAssertTrue(error is LpArgsError)
cupsOptions: "InputSlot=Rear;rm -rf /"))
} }
#expect(throws: LpArgsError.self) { XCTAssertThrowsError(try build(options: PrintOptions(
_ = try build(options: PrintOptions( cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
cupsOptions: "InputSlot=Rear\nMediaType=Photo")) XCTAssertTrue(error is LpArgsError)
} }
#expect(throws: LpArgsError.self) { XCTAssertThrowsError(try build(options: PrintOptions(
_ = try build(options: PrintOptions( cupsOptions: "InputSlot=$(whoami)"))) { error in
cupsOptions: "InputSlot=$(whoami)")) XCTAssertTrue(error is LpArgsError)
} }
} }
} }
+93 -128
View File
@@ -1,12 +1,10 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("InstrumentParser") final class InstrumentParserTests: XCTestCase {
struct InstrumentParserTests {
@Test("Parses pretty-printed instlist JSON") func testJson() throws {
func json() throws {
let json = """ let json = """
{ {
"event": "instruments", "event": "instruments",
@@ -18,134 +16,118 @@ struct InstrumentParserTests {
} }
""" """
let devices = try InstrumentParser.parse(json) let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3) XCTAssertEqual(devices.count, 3)
#expect(devices[0].port == 1) XCTAssertEqual(devices[0].port, 1)
#expect(devices[0].name == "X-Rite i1Pro") XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
#expect(devices[2].port == 3) XCTAssertEqual(devices[2].port, 3)
} }
@Test("Falls back to regex for legacy instlist text") func testRegexFallback() throws {
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)
#expect(devices.count == 2) XCTAssertEqual(devices.count, 2)
#expect(devices[0].port == 1) XCTAssertEqual(devices[0].port, 1)
#expect(devices[1].name == "ColorMunki Smile") XCTAssertEqual(devices[1].name, "ColorMunki Smile")
} }
@Test("Empty output returns no devices") func testEmpty() throws {
func empty() throws { XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
#expect(try InstrumentParser.parse("").isEmpty)
} }
} }
@Suite("ChartreadArgs") final class ChartreadArgsTests: XCTestCase {
struct ChartreadArgsTests {
@Test("Baseline argv and port 1 omits -c") func testBaseline() throws {
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)
#expect(args == ["-v", "-u", "target"]) XCTAssertEqual(args, ["-v", "-u", "target"])
} }
@Test("Port > 1 emits -c") func testPortArgument() throws {
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)
#expect(args == ["-v", "-u", "-c", "3", "target"]) XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"])
} }
@Test("LEDs emit -Y l") func testLeds() throws {
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)
#expect(args.contains("-Y")) XCTAssertTrue(args.contains("-Y"))
#expect(args.contains("l")) XCTAssertTrue(args.contains("l"))
} }
@Test("Auto omits -c") func testAutoPort() throws {
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)
#expect(!args.contains("-c")) XCTAssertFalse(args.contains("-c"))
} }
} }
@Suite("ChartreadClassifier") final class ChartreadClassifierTests: XCTestCase {
struct ChartreadClassifierTests {
@Test("Calibration prompt") func testCalibration() {
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
) )
#expect(r.state == .calibrating) XCTAssertEqual(r.state, .calibrating)
} }
@Test("Strip awaiting") func testAwaitingStrip() {
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
) )
#expect(r.state == .awaitingStrip) XCTAssertEqual(r.state, .awaitingStrip)
} }
@Test("Done prompt") func testDone() {
func done() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "'d' if/when done", line: "'d' if/when done",
previousState: .awaitingStrip previousState: .awaitingStrip
) )
#expect(r.state == .allStripsRead) XCTAssertEqual(r.state, .allStripsRead)
} }
@Test("XY place sheet") func testPlaceSheet() {
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
) )
#expect(r.state == .tablePlaceSheet) XCTAssertEqual(r.state, .tablePlaceSheet)
#expect(r.sheetNumber == 1) XCTAssertEqual(r.sheetNumber, 1)
#expect(r.sheetTotal == 2) XCTAssertEqual(r.sheetTotal, 2)
} }
@Test("XY locate patch") func testLocatePatch() {
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
) )
#expect(r.state == .tableAlign) XCTAssertEqual(r.state, .tableAlign)
#expect(r.alignmentPatch == "A1") XCTAssertEqual(r.alignmentPatch, "A1")
} }
@Test("Remove sheet notice preserves state") func testRemoveNotice() {
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
) )
#expect(r.state == .tablePlaceSheet) XCTAssertEqual(r.state, .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true) XCTAssertEqual(r.isRemoveSheetNotice, true)
} }
} }
@Suite("ChartreadRow") final class ChartreadRowTests: XCTestCase {
struct ChartreadRowTests {
@Test("Decodes row JSON") func testDecode() throws {
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": [
@@ -155,13 +137,12 @@ struct ChartreadRowTests {
]} ]}
""" """
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A") XCTAssertEqual(row.rowId, "A")
#expect(row.patchCount == 1) XCTAssertEqual(row.patchCount, 1)
#expect(row.patches[0].measured.lab?.l == 51) XCTAssertEqual(row.patches[0].measured.lab?.l, 51)
} }
@Test("Decodes a row carrying both XYZ and Lab arrays") func testDecodeXYZAndLab() throws {
func decodeXYZAndLab() throws {
let json = """ let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [ "patch_count": 1, "patches": [
@@ -171,88 +152,78 @@ struct ChartreadRowTests {
""" """
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured let measured = row.patches[0].measured
#expect(measured.xyz == CIEXYZ(x: 30.5, y: 32.1, z: 25.9)) XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
#expect(measured.lab == CIELab(l: 63.4, a: 2.5, b: -8.2)) XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
} }
@Test("XYZColor/CIEXYZ encode as an unkeyed three-number array") func testXyzWireEncoding() throws {
func xyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] { 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( let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) with: JSONEncoder().encode(color))
#expect(value as? [Double] == [1.5, 2.5, 3.5]) XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5])
} }
} }
@Test("LabColor/CIELab encode as an unkeyed three-number array") func testLabWireEncoding() throws {
func labWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] { for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject( let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) with: JSONEncoder().encode(color))
#expect(value as? [Double] == [50, -1, 2]) XCTAssertEqual(value as? [Double], [50, -1, 2])
} }
} }
@Test("PatchColor keeps the XYZ and Lab keys over unkeyed arrays") func testPatchColorKeys() throws {
func patchColorKeys() throws {
let color = PatchColor( let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30), xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2)) lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject( let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any] with: JSONEncoder().encode(color)) as? [String: Any]
#expect(object?["XYZ"] as? [Double] == [10, 20, 30]) XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
#expect(object?["Lab"] as? [Double] == [55, 1, -2]) XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
#expect(object?["spectral"] == nil) XCTAssertNil(object?["spectral"])
} }
} }
@Suite("ColourMath") final class ColourMathTests: XCTestCase {
struct ColourMathTests {
@Test("White XYZ to Lab") func testWhiteLab() {
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)
#expect(abs(lab.l - 100) < 0.5) XCTAssertTrue(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5) XCTAssertTrue(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5) XCTAssertTrue(abs(lab.b) < 0.5)
} }
@Test("Lab to sRGB roundtrip is clamped") func testLabToSRGB() {
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)
#expect(rgb.r > 0.8) XCTAssertTrue(rgb.r > 0.8)
#expect(rgb.g < 0.2) XCTAssertTrue(rgb.g < 0.2)
#expect(rgb.b < 0.2) XCTAssertTrue(rgb.b < 0.2)
} }
@Test("Pad white returns DisplayRGB") func testPadWhite() {
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)
#expect(rgb.r > 0.9) XCTAssertTrue(rgb.r > 0.9)
#expect(rgb.g > 0.9) XCTAssertTrue(rgb.g > 0.9)
#expect(rgb.b > 0.9) XCTAssertTrue(rgb.b > 0.9)
} }
@Test("Standard CIEDE2000 vector (Sharma)") func testCiede2000() {
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)
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001) XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
} }
@Test("Classification respects thresholds") func testClassify() {
func classify() { XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good)
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good) XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning) XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
} }
} }
@Suite("MeasurementArtefacts") final class MeasurementArtefactTests: XCTestCase {
struct MeasurementArtefactTests {
private func makeCwd() throws -> URL { private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
@@ -261,8 +232,7 @@ struct MeasurementArtefactTests {
return url return url
} }
@Test("Discovers passes in order") func testDiscovery() throws {
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) }
@@ -271,11 +241,10 @@ struct MeasurementArtefactTests {
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)
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"]) XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
} }
@Test("Snapshot and promote are atomic") func testSnapshotPromote() throws {
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) }
@@ -283,16 +252,15 @@ struct MeasurementArtefactTests {
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)
#expect(pass.lastPathComponent == "target_pass1.ti3") XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path)) XCTAssertFalse(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)
#expect(promoted.lastPathComponent == "target.ti3") XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
} }
@Test("Pass collisions handled") func testCollision() throws {
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) }
@@ -302,28 +270,25 @@ struct MeasurementArtefactTests {
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)
#expect(pass2.lastPathComponent == "target_pass2.ti3") XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
} }
} }
@Suite("AverageArgs") final class AverageArgsTests: XCTestCase {
struct AverageArgsTests {
@Test("Requires at least two pass files") func testPassCount() {
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")]
) )
#expect(throws: AverageArgError.self) { XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
_ = try AverageArgs.build(config: config) XCTAssertTrue(error is AverageArgError)
} }
} }
@Test("Output is last and inputs are relative") func testOrdering() throws {
func ordering() throws {
let cwd = URL(fileURLWithPath: "/tmp") let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
@@ -334,8 +299,8 @@ struct AverageArgsTests {
] ]
) )
let args = try AverageArgs.build(config: config) let args = try AverageArgs.build(config: config)
#expect(args.first == "-v") XCTAssertEqual(args.first, "-v")
#expect(args.last == "target.ti3") XCTAssertEqual(args.last, "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"]) XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
} }
} }
+13 -21
View File
@@ -1,24 +1,21 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("PrintcalArgs") final class PrintcalArgsTests: XCTestCase {
struct PrintcalArgsTests {
private let tmp = URL(fileURLWithPath: "/tmp/out.cal") private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
@Test("Default printcal argv") func testDefaults() throws {
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)
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
} }
@Test("All options and channel limits") func testAllOptions() throws {
func allOptions() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
@@ -32,7 +29,7 @@ struct PrintcalArgsTests {
] ]
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
#expect(args == [ XCTAssertEqual(args, [
"-v", "-e", "-v", "-e",
"-I", "-z", "-I", "-z",
"-a", "/tmp/old.cal", "-a", "/tmp/old.cal",
@@ -44,39 +41,34 @@ struct PrintcalArgsTests {
]) ])
} }
@Test("Whitespace-only previous calibration path emits no -a") func testWhitespacePreviousCal() throws {
func whitespacePreviousCal() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
previousCalPath: " \n\t " previousCalPath: " \n\t "
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
#expect(!args.contains("-a")) XCTAssertFalse(args.contains("-a"))
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
} }
@Test("Previous calibration path is trimmed before emission") func testPreviousCalTrimmed() throws {
func previousCalTrimmed() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
previousCalPath: " /tmp/old.cal " previousCalPath: " /tmp/old.cal "
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
#expect(args[args.firstIndex(of: "-a")! + 1] == "/tmp/old.cal") XCTAssertEqual(args[args.firstIndex(of: "-a")! + 1], "/tmp/old.cal")
} }
@Test("Rejects invalid per-channel limit") func testRejectsBadChannelLimit() {
func rejectsBadChannelLimit() {
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)]
) )
#expect(throws: (any Error).self) { XCTAssertThrowsError(try PrintcalArgs.build(config: config))
_ = try PrintcalArgs.build(config: config)
}
} }
} }
+81 -107
View File
@@ -1,9 +1,9 @@
import Testing import Testing
import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("PrinttargArgs") final class PrinttargArgsTests: XCTestCase {
struct PrinttargArgsTests {
private func config( private func config(
instrument: PrintInstrument = .i1, instrument: PrintInstrument = .i1,
@@ -28,136 +28,121 @@ struct PrinttargArgsTests {
) )
} }
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300") func testBaseline() throws {
func baseline() throws {
let args = try PrinttargArgs.build(config: config()) let args = try PrinttargArgs.build(config: config())
#expect(args == ["-v", "-u", "-i", "i1", "-p", "A4", XCTAssertEqual(args, ["-v", "-u", "-i", "i1", "-p", "A4",
"-R", "1", "-t", "300", "target"]) "-R", "1", "-t", "300", "target"])
} }
@Test("Default layout is deterministic -R 1, never bare") func testDeterministicDefault() throws {
func deterministicDefault() throws {
let args = try PrinttargArgs.build(config: config()) let args = try PrinttargArgs.build(config: config())
#expect(args.contains("-R")) XCTAssertTrue(args.contains("-R"))
#expect(!args.contains("-r")) XCTAssertFalse(args.contains("-r"))
#expect(args[args.firstIndex(of: "-R")! + 1] == "1") XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1")
} }
@Test("Custom seed -R N; seed < 1 throws") func testCustomSeed() 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))
#expect(args[args.firstIndex(of: "-R")! + 1] == "42") XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42")
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)") func testRaster() throws {
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))
#expect(args.contains("-r")) XCTAssertTrue(args.contains("-r"))
#expect(!args.contains("-R")) XCTAssertFalse(args.contains("-R"))
} }
@Test("Label: -d emits the resolved string, not a colour space") func testLabel() throws {
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")!
#expect(args[i + 1].hasPrefix("ICCery - t")) XCTAssertTrue(args[i + 1].hasPrefix("ICCery - t"))
} }
@Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600") func testBitDepthAndDPI() throws {
func bitDepthAndDPI() throws { XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
.contains("-T")) .contains("-T"))
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72)) XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
.contains("-t")) .contains("-t"))
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in
try PrinttargArgs.build(config: config(dpi: 71)) XCTAssertTrue(error is PrinttargArgError)
} }
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in
try PrinttargArgs.build(config: config(dpi: 601)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("All instruments emit their Argyll code") func testInstruments() throws {
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))
#expect(args[args.firstIndex(of: "-i")! + 1] == code) XCTAssertEqual(args[args.firstIndex(of: "-i")! + 1], code)
} }
} }
@Test("All fixed page sizes; custom emits WxH in mm") func testPageSizes() throws {
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))
#expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue) XCTAssertEqual(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))
#expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220") XCTAssertEqual(custom[custom.firstIndex(of: "-p")! + 1], "150x220")
} }
@Test("Custom page below 50 mm throws") func testCustomPageTooSmall() {
func customPageTooSmall() { XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in
#expect(throws: PrinttargArgError.self) { XCTAssertTrue(error is PrinttargArgError)
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
} }
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("Calibration: -K applies, -I embeds") func testCalibrationFlags() throws {
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"))
#expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal") XCTAssertEqual(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))
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal") XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal")
#expect(!i.contains("-K")) XCTAssertFalse(i.contains("-K"))
} }
@Test("CAL_ basename never gets -K or -I") func testCalProtection() throws {
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"))
#expect(!args.contains("-K")) XCTAssertFalse(args.contains("-K"))
#expect(!args.contains("-I")) XCTAssertFalse(args.contains("-I"))
} }
@Test("Whitespace-only label emits no -d; whitespace-only calibration emits no -K/-I") func testWhitespaceOptions() throws {
func whitespaceOptions() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(label: " \n ", calFile: " \t ")) config: config(label: " \n ", calFile: " \t "))
#expect(!args.contains("-d")) XCTAssertFalse(args.contains("-d"))
#expect(!args.contains("-K")) XCTAssertFalse(args.contains("-K"))
#expect(!args.contains("-I")) XCTAssertFalse(args.contains("-I"))
} }
@Test("Label and calibration values are trimmed before emission") func testTrimmedOptions() throws {
func trimmedOptions() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(label: " My Label ", calFile: " /tmp/a.cal ")) config: config(label: " My Label ", calFile: " /tmp/a.cal "))
#expect(args[args.firstIndex(of: "-d")! + 1] == "My Label") XCTAssertEqual(args[args.firstIndex(of: "-d")! + 1], "My Label")
#expect(args[args.firstIndex(of: "-K")! + 1] == "/tmp/a.cal") XCTAssertEqual(args[args.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
} }
@Test("Unsafe basename throws") func testUnsafeBasename() {
func unsafeBasename() { XCTAssertThrowsError(try PrinttargArgs.build(config: config(basename: "../x"))) { error in
#expect(throws: PathSecurity.Error.self) { XCTAssertTrue(error is PathSecurity.Error)
try PrinttargArgs.build(config: config(basename: "../x"))
} }
} }
} }
@Suite("PrinttargLabel") final class PrinttargLabelTests: XCTestCase {
struct PrinttargLabelTests {
private var fixedDate: Date { private var fixedDate: Date {
var comps = DateComponents() var comps = DateComponents()
@@ -166,37 +151,33 @@ struct PrinttargLabelTests {
return Calendar(identifier: .gregorian).date(from: comps)! return Calendar(identifier: .gregorian).date(from: comps)!
} }
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM") func testAutomatic() {
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)
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - ")) XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026")) XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
} }
@Test("Missing metadata becomes Unspecified") func testUnspecified() {
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)
#expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - ")) XCTAssertTrue(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
} }
@Test("Manual label wins over automatic") func testManualWins() {
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)
#expect(resolved == "My Label") XCTAssertEqual(resolved, "My Label")
} }
} }
@Suite("PrinttargManifest") final class PrinttargManifestTests: XCTestCase {
struct PrinttargManifestTests {
private let prettySingle = """ private let prettySingle = """
Some log line Some log line
@@ -225,59 +206,52 @@ struct PrinttargManifestTests {
} }
""" """
@Test("Decodes a single-page pretty manifest amid log noise") func testSinglePage() throws {
func singlePage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettySingle) let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
#expect(m.event == "manifest") XCTAssertEqual(m.event, "manifest")
#expect(m.pages.count == 1) XCTAssertEqual(m.pages.count, 1)
#expect(m.pages[0].filename == "target.tif") XCTAssertEqual(m.pages[0].filename, "target.tif")
#expect(m.pages[0].patches == 800) XCTAssertEqual(m.pages[0].patches, 800)
} }
@Test("Multi-page manifest preserves order") func testMultiPage() throws {
func multiPage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti) let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
#expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"]) XCTAssertEqual(m.pages.map(\.filename), ["p1.tif", "p2.tif"])
} }
@Test("No JSON document → noJSONDocument") func testNoJSON() {
func noJSON() { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in
#expect(throws: ManifestError.self) { XCTAssertTrue(error is ManifestError)
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
} }
} }
@Test("Wrong event → wrongEvent") func testWrongEvent() {
func wrongEvent() {
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n" let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
#expect(throws: ManifestError.self) { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
try PrinttargManifestExtractor.manifest(from: stdout) XCTAssertTrue(error is ManifestError)
} }
} }
@Test("ROW_COLORS_JSON line is never treated as the manifest") func testRowColorsNotManifest() {
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)
#expect(m != nil) XCTAssertNotNil(m)
#expect(m?.event == "manifest") XCTAssertEqual(m?.event, "manifest")
} }
@Test("Braces inside a quoted filename do not corrupt the scan") func testBracesInFilename() throws {
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)
#expect(m.pages[0].filename == "a}b.tif") XCTAssertEqual(m.pages[0].filename, "a}b.tif")
} }
@Test("Unsafe / non-TIFF filenames rejected") func testUnsafeFilenames() {
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}"
#expect(throws: ManifestError.self) { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
try PrinttargManifestExtractor.manifest(from: stdout) XCTAssertTrue(error is ManifestError)
} }
} }
} }
+30 -32
View File
@@ -1,4 +1,5 @@
import Testing import Testing
import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -408,65 +409,62 @@ struct ProcessManagerTests {
} }
} }
@Suite("ProcessLineDecoder") final class ProcessLineDecoderTests: XCTestCase {
struct ProcessLineDecoderTests { func testSplitsAcrossChunkBoundaries() {
@Test func splitsAcrossChunkBoundaries() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
#expect(d.feed(Data("he".utf8)) == []) XCTAssertEqual(d.feed(Data("he".utf8)), [])
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"]) XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"])
#expect(d.feed(Data("ld\n".utf8)) == ["world"]) XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"])
#expect(d.finish() == nil) XCTAssertNil(d.finish())
} }
@Test func crlfIsStripped() { func testCrlfIsStripped() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"]) XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"])
} }
@Test func finishReturnsRemainder() { func testFinishReturnsRemainder() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
_ = d.feed(Data("x".utf8)) _ = d.feed(Data("x".utf8))
#expect(d.finish() == "x") XCTAssertEqual(d.finish(), "x")
#expect(d.finish() == nil) XCTAssertNil(d.finish())
} }
} }
@Suite("JSONAccumulator") final class JSONAccumulatorTests: XCTestCase {
struct JSONAccumulatorTests { func testMultilinePrettyJSON() {
@Test func multilinePrettyJSON() {
var acc = JSONAccumulator() var acc = JSONAccumulator()
#expect(acc.feed(line: "{") == nil) XCTAssertNil(acc.feed(line: "{"))
#expect(acc.feed(line: " \"k\": 1") == nil) XCTAssertNil(acc.feed(line: " \"k\": 1"))
let done = acc.feed(line: "}") let done = acc.feed(line: "}")
#expect(done != nil) XCTAssertNotNil(done)
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int] let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
#expect(obj?["k"] == 1) XCTAssertEqual(obj?["k"], 1)
} }
@Test func nonJSONLinesIgnored() { func testNonJSONLinesIgnored() {
var acc = JSONAccumulator() var acc = JSONAccumulator()
#expect(acc.feed(line: "Reading instrument...") == nil) XCTAssertNil(acc.feed(line: "Reading instrument..."))
#expect(acc.feed(line: "still text") == nil) XCTAssertNil(acc.feed(line: "still text"))
#expect(acc.completeData == nil) XCTAssertNil(acc.completeData)
} }
@Test func decodeTyped() { func testDecodeTyped() {
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.
#expect(acc.feed(line: "{\"n\":") == nil) XCTAssertNil(acc.feed(line: "{\"n\":"))
let data = acc.feed(line: "7}") let data = acc.feed(line: "7}")
#expect(data != nil) XCTAssertNotNil(data)
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) } let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
#expect(doc?.n == 7) XCTAssertEqual(doc?.n, 7)
#expect(acc.isEmpty) XCTAssertTrue(acc.isEmpty)
} }
} }
@Suite("LogSanitizer") final class LogSanitizerTests: XCTestCase {
struct LogSanitizerTests { func testHomeIsRewritten() {
@Test func homeIsRewritten() {
let path = "\(NSHomeDirectory())/Documents/foo.ti1" let path = "\(NSHomeDirectory())/Documents/foo.ti1"
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1") XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1")
} }
} }
@@ -0,0 +1,67 @@
import Foundation
import Testing
@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.
@Suite("ProcessRunSupport runLogged")
@MainActor
struct ProcessRunSupportTests {
private struct SentinelError: Error {}
@Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved")
func successTransitions() 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()
received.append(contentsOf: batch)
}
) { onLog in
onLog(["alpha", "beta"])
return 42
}
#expect(result == 42)
#expect(running == [true, false])
#expect(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(for: .milliseconds(10))
}
#expect(received == ["alpha", "beta"])
}
@Test("Failure: running still transitions [true, false], log resets once, error is rethrown")
func failureTransitions() 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()
}
Issue.record("Expected runLogged to rethrow")
} catch is SentinelError {
// Expected path.
}
#expect(running == [true, false])
#expect(resets == 1)
}
}
@@ -1,17 +1,15 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfcheckArgs") final class ProfcheckArgsTests: XCTestCase {
struct ProfcheckArgsTests {
@Test("Hard-coded argv") func testArgv() throws {
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)
#expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"]) XCTAssertEqual(args, ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
} }
} }
@@ -1,43 +1,39 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfcheckParser") final class ProfcheckParserTests: XCTestCase {
struct ProfcheckParserTests {
@Test("Prefers JSON report with de2000 keys") func testJsonReport() {
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)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.patchCount == 52) XCTAssertEqual(report.patchCount, 52)
#expect(report.avgDE == 0.85) XCTAssertEqual(report.avgDE, 0.85)
#expect(report.maxDE == 2.41) XCTAssertEqual(report.maxDE, 2.41)
#expect(report.rmsDE == 1.02) XCTAssertEqual(report.rmsDE, 1.02)
#expect(report.status == .excellent) XCTAssertEqual(report.status, .excellent)
} }
@Test("Falls back to legacy text") func testLegacyText() {
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)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.patchCount == 120) XCTAssertEqual(report.patchCount, 120)
#expect(report.avgDE == 1.80) XCTAssertEqual(report.avgDE, 1.80)
#expect(report.maxDE == 3.50) XCTAssertEqual(report.maxDE, 3.50)
#expect(report.rmsDE == 0.95) XCTAssertEqual(report.rmsDE, 0.95)
#expect(report.status == .good) XCTAssertEqual(report.status, .good)
} }
@Test("Broad regex fallback") func testRegexFallback() {
func regexFallback() {
let output = """ let output = """
No of test patches = 10 No of test patches = 10
avg = 4.25 avg = 4.25
@@ -45,27 +41,25 @@ struct ProfcheckParserTests {
rms = 2.30 rms = 2.30
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.avgDE == 4.25) XCTAssertEqual(report.avgDE, 4.25)
#expect(report.maxDE == 6.10) XCTAssertEqual(report.maxDE, 6.10)
#expect(report.rmsDE == 2.30) XCTAssertEqual(report.rmsDE, 2.30)
#expect(report.status == .poor) XCTAssertEqual(report.status, .poor)
} }
@Test("Unparseable output warns, not zeros") func testUnparseable() {
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)
#expect(report.isValid == false) XCTAssertEqual(report.isValid, false)
#expect(report.warning != nil) XCTAssertNotNil(report.warning)
#expect(report.avgDE == nil) XCTAssertNil(report.avgDE)
} }
@Test("Status bands") func testStatusBands() {
func statusBands() { XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent)
#expect(VerificationStatus.from(avgDE: 0.5) == .excellent) XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good)
#expect(VerificationStatus.from(avgDE: 1.5) == .good) XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable)
#expect(VerificationStatus.from(avgDE: 2.5) == .acceptable) XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor)
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
} }
} }
+38 -52
View File
@@ -1,12 +1,11 @@
import Testing import Testing
import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("TargenArgs") final class TargenArgsTests: XCTestCase {
struct TargenArgsTests {
@Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4") func testRgbBaseline() throws {
func rgbBaseline() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -15,12 +14,11 @@ struct TargenArgsTests {
basename: "test_rgb" basename: "test_rgb"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"]) XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
#expect(!args.contains("-u")) XCTAssertFalse(args.contains("-u"))
} }
@Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0") func testCmykBaseline() throws {
func cmykBaseline() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 1500, patchCount: 1500,
@@ -29,11 +27,10 @@ struct TargenArgsTests {
basename: "test_cmyk" basename: "test_cmyk"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"]) XCTAssertEqual(args, ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
} }
@Test("Custom patch count honours -f (#44)") func testCustomPatchCount() throws {
func customPatchCount() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 2500, patchCount: 2500,
@@ -42,12 +39,11 @@ struct TargenArgsTests {
basename: "custom_patches" basename: "custom_patches"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args.contains("-f")) XCTAssertTrue(args.contains("-f"))
#expect(args[args.firstIndex(of: "-f")! + 1] == "2500") XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500")
} }
@Test("All advanced flags in stable order") func testAllAdvancedFlags() throws {
func allAdvancedFlags() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 1200, patchCount: 1200,
@@ -85,11 +81,10 @@ struct TargenArgsTests {
"-p", "2.00", "-p", "2.00",
"advanced_cmyk" "advanced_cmyk"
] ]
#expect(args == expected) XCTAssertEqual(args, expected)
} }
@Test("RGB ignores total ink limit") func testRgbIgnoresInkLimit() throws {
func rgbIgnoresInkLimit() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -99,11 +94,10 @@ struct TargenArgsTests {
basename: "rgb_no_ink" basename: "rgb_no_ink"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-l")) XCTAssertFalse(args.contains("-l"))
} }
@Test("Neutral concentration omitted when approximately 0.50") func testNeutralConcentrationOmittedWhenDefault() throws {
func neutralConcentrationOmittedWhenDefault() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -113,11 +107,10 @@ struct TargenArgsTests {
basename: "n_default" basename: "n_default"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-N")) XCTAssertFalse(args.contains("-N"))
} }
@Test("Adaptation emitted even at 0.10 (no default-skip)") func testAdaptationEmittedAtPointOne() throws {
func adaptationEmittedAtPointOne() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -127,12 +120,11 @@ struct TargenArgsTests {
basename: "a_flag" basename: "a_flag"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args.contains("-A")) XCTAssertTrue(args.contains("-A"))
#expect(args[args.firstIndex(of: "-A")! + 1] == "0.10") XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10")
} }
@Test("OFPS full spread algorithm emits no flag") func testOfpsEmitsNoFlag() throws {
func ofpsEmitsNoFlag() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -142,12 +134,11 @@ struct TargenArgsTests {
basename: "ofps_test" basename: "ofps_test"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("ofps")) XCTAssertFalse(args.contains("ofps"))
#expect(!args.contains("-t")) XCTAssertFalse(args.contains("-t"))
} }
@Test("Dark emphasis and device power omitted when 1.0") func testDarkEmphasisAndPowerOmittedWhenOne() throws {
func darkEmphasisAndPowerOmittedWhenOne() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -158,12 +149,11 @@ struct TargenArgsTests {
basename: "defaults_omitted" basename: "defaults_omitted"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-V")) XCTAssertFalse(args.contains("-V"))
#expect(!args.contains("-p")) XCTAssertFalse(args.contains("-p"))
} }
@Test("Whitespace-only preconditioning profile emits no -c") func testWhitespacePreconditioner() throws {
func whitespacePreconditioner() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -173,11 +163,10 @@ struct TargenArgsTests {
basename: "ws_pre" basename: "ws_pre"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-c")) XCTAssertFalse(args.contains("-c"))
} }
@Test("Preconditioning profile is trimmed before emission") func testPreconditionerTrimmed() throws {
func preconditionerTrimmed() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -187,11 +176,10 @@ struct TargenArgsTests {
basename: "trim_pre" basename: "trim_pre"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args[args.firstIndex(of: "-c")! + 1] == "/path/to/profile.icc") XCTAssertEqual(args[args.firstIndex(of: "-c")! + 1], "/path/to/profile.icc")
} }
@Test("Invalid basename throws") func testInvalidBasenameThrows() {
func invalidBasenameThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -199,13 +187,12 @@ struct TargenArgsTests {
blackPatches: 4, blackPatches: 4,
basename: "../bad_name" basename: "../bad_name"
) )
#expect(throws: PathSecurity.Error.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is PathSecurity.Error)
} }
} }
@Test("Invalid patch count throws") func testInvalidPatchCountThrows() {
func invalidPatchCountThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 0, patchCount: 0,
@@ -213,13 +200,12 @@ struct TargenArgsTests {
blackPatches: 4, blackPatches: 4,
basename: "bad_count" basename: "bad_count"
) )
#expect(throws: TargenArgError.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is TargenArgError)
} }
} }
@Test("Invalid ink limit throws for CMYK") func testInvalidInkLimitThrows() {
func invalidInkLimitThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 800, patchCount: 800,
@@ -228,8 +214,8 @@ struct TargenArgsTests {
totalInkLimit: 450, totalInkLimit: 450,
basename: "bad_ink" basename: "bad_ink"
) )
#expect(throws: TargenArgError.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is TargenArgError)
} }
} }
} }
@@ -0,0 +1,42 @@
import Foundation
import Testing
@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.
@Suite("TargetWorkflowViewModel dataset import")
@MainActor
struct TargetWorkflowViewModelTests {
@Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'")
func malformedDatasetNotice() 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 #require(vm.wizard.notice)
#expect(notice.kind == .error)
#expect(notice.text.hasPrefix("Import failed:"))
}
@Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'")
func missingDatasetNotice() 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 #require(vm.wizard.notice)
#expect(notice.kind == .error)
#expect(notice.text.hasPrefix("Import failed:"))
}
}
@@ -1,4 +1,3 @@
import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
@@ -0,0 +1,21 @@
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,6 +133,18 @@ 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,6 +146,8 @@ 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
@@ -214,6 +216,8 @@ 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,4 +140,57 @@ 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,4 +111,21 @@ 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,6 +32,7 @@ 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 {
@@ -77,4 +78,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
let layout = app.buttons["btnCreateLayout"] let layout = app.buttons["btnCreateLayout"]
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: "14.0" macOS: "12.0"
groupSortPosition: top groupSortPosition: top
packages: packages:
@@ -13,7 +13,7 @@ targets:
ICCery: ICCery:
type: application type: application
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.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: "14.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.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: "6.0" SWIFT_VERSION: "5.0"
SWIFT_STRICT_CONCURRENCY: complete OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ARCHS: "$(ARCHS_STANDARD)" ARCHS: "$(ARCHS_STANDARD)"
ICCeryCoreTests: ICCeryCoreTests:
type: bundle.unit-test type: bundle.unit-test
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.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: "6.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ICCeryUITests: ICCeryUITests:
type: bundle.ui-testing type: bundle.ui-testing
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.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: "6.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
schemes: schemes:
ICCery: ICCery: