Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9a2042bf |
@@ -14,21 +14,13 @@ jobs:
|
|||||||
build-and-test:
|
build-and-test:
|
||||||
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
|
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
|
||||||
# macos-14 works for this pipeline.
|
# macos-14 works for this pipeline.
|
||||||
runs-on: macos-12
|
runs-on: macos-14
|
||||||
env:
|
env:
|
||||||
DERIVED: build/DerivedData-test
|
DERIVED: build/DerivedData-test
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Assert Xcode 14 toolchain
|
|
||||||
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
|
|
||||||
|
|
||||||
- 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
|
||||||
|
|
||||||
@@ -128,7 +120,7 @@ jobs:
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
needs: build-and-test
|
needs: build-and-test
|
||||||
runs-on: macos-12
|
runs-on: macos-14
|
||||||
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -27,5 +27,4 @@ ICCery.xcodeproj/
|
|||||||
Release/
|
Release/
|
||||||
notarization/
|
notarization/
|
||||||
build/
|
build/
|
||||||
docs/megaplans/*
|
|
||||||
docs/megaplans
|
docs/megaplans
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// swift-tools-version: 5.7
|
// swift-tools-version: 6.0
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ICCeryCore",
|
name: "ICCeryCore",
|
||||||
platforms: [.macOS(.v12)],
|
platforms: [.macOS(.v14)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.kill(id: id)
|
await processManager.kill(id: id)
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
while await processManager.isRunning(id), attempts < 30 {
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +243,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
if flushPartialLines {
|
if flushPartialLines {
|
||||||
dotFlushTask = Task { [processManager] in
|
dotFlushTask = Task { [processManager] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
await processManager.flushPartialLine(id: processId)
|
await processManager.flushPartialLine(id: processId)
|
||||||
}
|
}
|
||||||
@@ -592,7 +592,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.setPreKillHook(id: processId) { [processManager] in
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
if isXY {
|
if isXY {
|
||||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,19 +56,23 @@ public enum PrinttargArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
||||||
case .raster:
|
case .raster:
|
||||||
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
|
args.append("-r")
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
|
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!label.isEmpty {
|
||||||
|
args.append(contentsOf: ["-d", label])
|
||||||
|
}
|
||||||
|
|
||||||
guard (72...600).contains(config.dpi) else {
|
guard (72...600).contains(config.dpi) else {
|
||||||
throw PrinttargArgError.invalidDPI(config.dpi)
|
throw PrinttargArgError.invalidDPI(config.dpi)
|
||||||
}
|
}
|
||||||
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||||
|
|
||||||
if !CalibrationIdentity.isCalibration(cleanBasename) {
|
if !CalibrationIdentity.isCalibration(cleanBasename),
|
||||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
|
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
|
!cal.isEmpty {
|
||||||
|
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -70,12 +70,18 @@ public enum TargenArgs {
|
|||||||
if let n = config.neutralSteps, n > 0 {
|
if let n = config.neutralSteps, n > 0 {
|
||||||
args.append(contentsOf: ["-n", "\(n)"])
|
args.append(contentsOf: ["-n", "\(n)"])
|
||||||
}
|
}
|
||||||
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
|
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
|
||||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
|
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
|
||||||
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
|
}
|
||||||
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
|
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
|
||||||
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
|
args.append(contentsOf: ["-c", c])
|
||||||
}))
|
}
|
||||||
|
if config.ofpsHighQuality == true {
|
||||||
|
args.append("-G")
|
||||||
|
}
|
||||||
|
if let a = config.ofpsAdaptation {
|
||||||
|
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
|
||||||
|
}
|
||||||
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
||||||
args.append(algFlag)
|
args.append(algFlag)
|
||||||
}
|
}
|
||||||
@@ -85,9 +91,11 @@ public enum TargenArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
}
|
}
|
||||||
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
|
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
|
||||||
if let p = config.devicePower, p > 0 {
|
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
|
||||||
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
|
}
|
||||||
|
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
|
||||||
|
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ public enum CGATSParser {
|
|||||||
public static func parse(
|
public static func parse(
|
||||||
_ contents: String,
|
_ contents: String,
|
||||||
sourceURL: URL? = nil
|
sourceURL: URL? = nil
|
||||||
) throws -> CGATSDataset {
|
) throws(CGATSParseError) -> CGATSDataset {
|
||||||
guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
|
guard !contents.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
||||||
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -101,10 +101,10 @@ public enum CGATSParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
|
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
|
||||||
throw CGATSParseError.missingBeginDataFormat
|
throw .missingBeginDataFormat
|
||||||
}
|
}
|
||||||
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
||||||
throw CGATSParseError.missingBeginData
|
throw .missingBeginData
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawFieldNames = splitFields(lines[formatStart + 1])
|
let rawFieldNames = splitFields(lines[formatStart + 1])
|
||||||
@@ -139,7 +139,7 @@ public enum CGATSParser {
|
|||||||
let lineIndex = dataStart + offset
|
let lineIndex = dataStart + offset
|
||||||
let rawRow = splitFields(lines[lineIndex])
|
let rawRow = splitFields(lines[lineIndex])
|
||||||
guard rawRow.count == fieldNames.count else {
|
guard rawRow.count == fieldNames.count else {
|
||||||
throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
|
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
|
||||||
@@ -153,7 +153,7 @@ public enum CGATSParser {
|
|||||||
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
|
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
|
||||||
}
|
}
|
||||||
} else if !cleaned.isEmpty {
|
} else if !cleaned.isEmpty {
|
||||||
throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sample.strings[name] = raw
|
sample.strings[name] = raw
|
||||||
@@ -213,7 +213,7 @@ public enum CGATSParser {
|
|||||||
private static func preprocess(
|
private static func preprocess(
|
||||||
_ contents: String,
|
_ contents: String,
|
||||||
isCSV: Bool
|
isCSV: Bool
|
||||||
) throws -> (CGATSFormat, [String]) {
|
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
|
||||||
let allLines = contents.components(separatedBy: .newlines)
|
let allLines = contents.components(separatedBy: .newlines)
|
||||||
var lines = [String]()
|
var lines = [String]()
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ public enum CGATSParser {
|
|||||||
lines.append(line)
|
lines.append(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !lines.isEmpty else { throw CGATSParseError.emptyFile }
|
guard !lines.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
||||||
// structure so the boundary-based parser below can handle it.
|
// structure so the boundary-based parser below can handle it.
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ public actor ProcessManager {
|
|||||||
// Start a watchdog in case the `readabilityHandler` EOFs never
|
// Start a watchdog in case the `readabilityHandler` EOFs never
|
||||||
// arrive after the process exits (e.g. a hung pipe).
|
// arrive after the process exits (e.g. a hung pipe).
|
||||||
child.finalizeTask = Task { [weak self] in
|
child.finalizeTask = Task { [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(for: .seconds(2))
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.forceKill(id: id)
|
await self.forceKill(id: id)
|
||||||
await self.forceFinalize(id: id)
|
await self.forceFinalize(id: id)
|
||||||
|
|||||||
@@ -79,9 +79,16 @@ public enum PrintcalArgs {
|
|||||||
|
|
||||||
var args: [String] = ["-v", "-e"]
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
|
if config.noInkLimit {
|
||||||
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
|
args.append("-I")
|
||||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
|
}
|
||||||
|
if config.verify {
|
||||||
|
args.append("-z")
|
||||||
|
}
|
||||||
|
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!previous.isEmpty {
|
||||||
|
args.append(contentsOf: ["-a", previous])
|
||||||
|
}
|
||||||
if let tac = config.totalInkLimit, tac > 0 {
|
if let tac = config.totalInkLimit, tac > 0 {
|
||||||
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
||||||
} else if let tac = config.totalInkLimit {
|
} else if let tac = config.totalInkLimit {
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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
|
||||||
@@ -76,6 +77,10 @@ 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,
|
||||||
@@ -87,19 +92,17 @@ final class CalibrationViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
defer { self.isGenerating = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await ProcessRunSupport.runLogged(
|
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
setRunning: { self.isGenerating = $0 },
|
self?.calibrationLog.append(contentsOf: batch)
|
||||||
resetLog: { self.calibrationLog = [] },
|
})
|
||||||
onLog: { self.calibrationLog.append(contentsOf: $0) }
|
|
||||||
) { onLog in
|
|
||||||
try await self.environment.runner.runCalibrationTargen(
|
|
||||||
config: config, onLogBatch: onLog)
|
|
||||||
}
|
|
||||||
self.wizard.refreshGating()
|
self.wizard.refreshGating()
|
||||||
self.wizard.showNotice("Calibration target generated.")
|
self.wizard.showNotice("Calibration target generated.")
|
||||||
self.wizard.go(to: .layOutPrint)
|
self.wizard.go(to: .layOutPrint)
|
||||||
} catch {
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Calibration target failed: \(error.localizedDescription)",
|
"Calibration target failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
@@ -134,13 +137,15 @@ 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) {
|
||||||
wizard.showNotice(
|
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
|
||||||
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
|
wizard.showNotice(lastError!, kind: .error)
|
||||||
kind: .error
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isComputing = true
|
||||||
|
calibrationLog = []
|
||||||
|
lastError = nil
|
||||||
|
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: calBasename,
|
ti3Basename: calBasename,
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
@@ -153,21 +158,19 @@ final class CalibrationViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
defer { self.isComputing = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let url = try await ProcessRunSupport.runLogged(
|
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
setRunning: { self.isComputing = $0 },
|
self?.calibrationLog.append(contentsOf: batch)
|
||||||
resetLog: { self.calibrationLog = [] },
|
})
|
||||||
onLog: { self.calibrationLog.append(contentsOf: $0) }
|
|
||||||
) { onLog in
|
|
||||||
try await self.environment.runner.runPrintcal(
|
|
||||||
config: config, onLogBatch: onLog)
|
|
||||||
}
|
|
||||||
self.computedCalURL = url
|
self.computedCalURL = url
|
||||||
self.profile.calibrationFile = url.path
|
self.profile.calibrationFile = url.path
|
||||||
self.profile.applyCalibration = self.applyToProfile
|
self.profile.applyCalibration = self.applyToProfile
|
||||||
self.wizard.showNotice("Calibration curves computed.")
|
self.wizard.showNotice("Calibration curves computed.")
|
||||||
self.wizard.restoreCalibration()
|
self.wizard.restoreCalibration()
|
||||||
} catch {
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Calibration curve computation failed: \(error.localizedDescription)",
|
"Calibration curve computation failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
var rows: [ChartreadRow] = []
|
var rows: [ChartreadRow] = []
|
||||||
var swatchRows: [SwatchRow] = []
|
var swatchRows: [SwatchRow] = []
|
||||||
var showRemoveSheetNotice = false
|
var showRemoveSheetNotice = false
|
||||||
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
|
var lastError: String?
|
||||||
var chartreadNotice: Notice?
|
|
||||||
private var chartreadTask: Task<Void, Never>?
|
private var chartreadTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Averaging
|
// MARK: - Averaging
|
||||||
@@ -186,7 +185,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
isChartreadRunning = true
|
isChartreadRunning = true
|
||||||
chartreadState = .idle
|
chartreadState = .idle
|
||||||
currentPrompt = nil
|
currentPrompt = nil
|
||||||
chartreadNotice = nil
|
lastError = nil
|
||||||
chartreadLog.removeAll()
|
chartreadLog.removeAll()
|
||||||
|
|
||||||
// Optional: reset rows when starting a fresh first pass.
|
// Optional: reset rows when starting a fresh first pass.
|
||||||
@@ -228,10 +227,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
case .exit(let code):
|
case .exit(let code):
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
chartreadNotice = Notice(
|
lastError = "chartread exited with code \(code)"
|
||||||
kind: .error,
|
|
||||||
text: "chartread exited with code \(code)"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .completed(let canonicalURL):
|
case .completed(let canonicalURL):
|
||||||
@@ -239,7 +235,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
completePass(canonicalURL: canonicalURL)
|
completePass(canonicalURL: canonicalURL)
|
||||||
|
|
||||||
case .failed(let error):
|
case .failed(let error):
|
||||||
chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
|
lastError = error.localizedDescription
|
||||||
chartreadState = .error
|
chartreadState = .error
|
||||||
isChartreadRunning = false
|
isChartreadRunning = false
|
||||||
}
|
}
|
||||||
@@ -385,10 +381,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
discoverPassSnapshots()
|
discoverPassSnapshots()
|
||||||
wizard.refreshGating()
|
wizard.refreshGating()
|
||||||
} catch {
|
} catch {
|
||||||
chartreadNotice = Notice(
|
lastError = "Could not snapshot pass: \(error.localizedDescription)"
|
||||||
kind: .error,
|
|
||||||
text: "Could not snapshot pass: \(error.localizedDescription)"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,32 +397,30 @@ 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 {
|
||||||
// No log reset: prior chartread output must be preserved.
|
let canonical: URL
|
||||||
let canonical = try await ProcessRunSupport.runLogged(
|
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
||||||
setRunning: { self.isFinishing = $0 },
|
canonical = try MeasurementArtefacts.promotePass(
|
||||||
resetLog: {},
|
pass: pass,
|
||||||
onLog: { self.chartreadLog.append(contentsOf: $0) }
|
basename: self.basename,
|
||||||
) { onLog in
|
cwd: cwd
|
||||||
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
)
|
||||||
return try MeasurementArtefacts.promotePass(
|
} else {
|
||||||
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
|
||||||
)
|
)
|
||||||
return try await self.environment.runner.runAverage(
|
canonical = try await self.environment.runner.runAverage(
|
||||||
config: config,
|
config: config,
|
||||||
onLogBatch: onLog
|
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
|
self?.chartreadLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
self.discoverPassSnapshots()
|
self.discoverPassSnapshots()
|
||||||
@@ -474,6 +465,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.isFinishing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,6 @@ struct Notice: Identifiable, Equatable {
|
|||||||
case .error: return .red
|
case .error: return .red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var accessibilityValue: String {
|
|
||||||
switch self {
|
|
||||||
case .info: return "info"
|
|
||||||
case .warning: return "warning"
|
|
||||||
case .error: return "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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?
|
||||||
@@ -164,59 +165,60 @@ 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 outcome = try await ProcessRunSupport.runLogged(
|
let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
setRunning: { self.isColprofRunning = $0 },
|
guard let self else { return }
|
||||||
resetLog: { self.colprofLog = [] },
|
self.colprofLog.append(contentsOf: batch)
|
||||||
onLog: { batch in
|
if let last = batch.last {
|
||||||
self.colprofLog.append(contentsOf: batch)
|
self.updateProgress(ColprofProgressClassifier.classify(line: last))
|
||||||
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
|
|
||||||
self.createdGamutURL = outcome.gamutURL
|
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||||
|
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: \(outcome.profileURL.lastPathComponent)")
|
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
|
||||||
self.wizard.go(to: .verifyInstall)
|
self.wizard.go(to: .verifyInstall)
|
||||||
} catch {
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Profile creation failed: \(error.localizedDescription)",
|
"Profile creation failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
@@ -283,28 +285,23 @@ 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 outcome = try await ProcessRunSupport.runLogged(
|
let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
setRunning: { self.isProfcheckRunning = $0 },
|
self?.colprofLog.append(contentsOf: batch)
|
||||||
resetLog: {},
|
})
|
||||||
onLog: { self.colprofLog.append(contentsOf: $0) }
|
self.profcheckReport = report
|
||||||
) { onLog in
|
if let record = self.makeVerificationRecord(from: report) {
|
||||||
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
|
let updated = try await self.environment.historyStore.append(record)
|
||||||
var history: [VerificationRecord]?
|
self.verificationHistory = updated
|
||||||
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 {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ final class SettingsViewModel {
|
|||||||
sink.applySettings(settings)
|
sink.applySettings(settings)
|
||||||
savedFlash = true
|
savedFlash = true
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
savedFlash = false
|
savedFlash = false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ struct Stage2View: View {
|
|||||||
.foregroundStyle(notice.kind == .error
|
.foregroundStyle(notice.kind == .error
|
||||||
? .red : .blue)
|
? .red : .blue)
|
||||||
.accessibilityIdentifier("printNotificationIcon")
|
.accessibilityIdentifier("printNotificationIcon")
|
||||||
.accessibilityValue(notice.kind.accessibilityValue)
|
|
||||||
Text(notice.text)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(notice.kind == .error
|
.foregroundStyle(notice.kind == .error
|
||||||
|
|||||||
@@ -164,22 +164,28 @@ struct Stage3View: View {
|
|||||||
.foregroundStyle(Theme.accent)
|
.foregroundStyle(Theme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let notice = model.chartreadNotice {
|
if let lastError = model.lastError {
|
||||||
Text(notice.text)
|
Text(lastError)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(notice.kind.tint)
|
.foregroundStyle(.red)
|
||||||
.accessibilityIdentifier("chartreadLastError")
|
.accessibilityIdentifier("chartreadLastError")
|
||||||
.accessibilityValue(notice.text)
|
.accessibilityValue(lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
controlButtons
|
controlButtons
|
||||||
|
|
||||||
if !model.chartreadLog.isEmpty {
|
if !model.chartreadLog.isEmpty {
|
||||||
ProcessLogView(
|
DisclosureGroup("Log") {
|
||||||
lines: model.chartreadLog,
|
VStack(alignment: .leading) {
|
||||||
containerId: "chartreadLogContainer",
|
ForEach(model.chartreadLog, id: \.self) { line in
|
||||||
logId: "chartreadLog"
|
Text(line)
|
||||||
)
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("chartreadLogContainer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
@@ -368,8 +374,6 @@ struct Stage3View: View {
|
|||||||
Text(notice.text)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(notice.kind == .error ? .red : .green)
|
.foregroundStyle(notice.kind == .error ? .red : .green)
|
||||||
.accessibilityIdentifier("chartreadFinishNotice")
|
|
||||||
.accessibilityValue(notice.kind.accessibilityValue)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -166,14 +166,27 @@ struct Stage4View: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
if let lastError = model.lastError {
|
||||||
|
Text(lastError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("colprofLastError")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !model.colprofLog.isEmpty {
|
if !model.colprofLog.isEmpty {
|
||||||
ProcessLogView(
|
DisclosureGroup("Log") {
|
||||||
lines: model.colprofLog,
|
VStack(alignment: .leading) {
|
||||||
containerId: "colprofLogContainer",
|
ForEach(model.colprofLog, id: \.self) { line in
|
||||||
logId: "colprofLog"
|
Text(line)
|
||||||
)
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("colprofLogContainer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ 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
|
||||||
@@ -226,6 +228,7 @@ final class TargetWorkflowViewModel {
|
|||||||
} catch {
|
} catch {
|
||||||
wizard.showNotice(
|
wizard.showNotice(
|
||||||
"targen failed: \(error.localizedDescription)", kind: .error)
|
"targen failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
targenRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,12 +242,7 @@ 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 {
|
||||||
@@ -341,6 +339,8 @@ 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
|
||||||
|
|||||||
@@ -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(nanoseconds: UInt64(delay * 1_000_000_000))
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
if self?.notice?.id == notice.id {
|
if self?.notice?.id == notice.id {
|
||||||
self?.notice = nil
|
self?.notice = nil
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class AppPathsTests: XCTestCase {
|
@Suite("AppPaths")
|
||||||
func testAppDataDirUsesBundleID() {
|
struct AppPathsTests {
|
||||||
XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
@Test func appDataDirUsesBundleID() {
|
||||||
|
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLogFileIsUnderLibraryLogs() {
|
@Test func logFileIsUnderLibraryLogs() {
|
||||||
XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
|
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
|
||||||
XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBundledArgyllDirIsInsideResources() {
|
@Test func bundledArgyllDirIsInsideResources() {
|
||||||
XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
|
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class WizardStageTests: XCTestCase {
|
@Suite("WizardStage")
|
||||||
func testStepperOrderIsOneThroughFive() {
|
struct WizardStageTests {
|
||||||
XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
|
@Test func stepperOrderIsOneThroughFive() {
|
||||||
XCTAssertNil(WizardStage.calibrate.stepperIndex)
|
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
|
||||||
|
#expect(WizardStage.calibrate.stepperIndex == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class ApplycalArgsTests: XCTestCase {
|
@Suite("ApplycalArgs")
|
||||||
|
struct ApplycalArgsTests {
|
||||||
|
|
||||||
func testApplyArgv() throws {
|
@Test("Apply argv")
|
||||||
|
func applyArgv() throws {
|
||||||
let config = ApplycalConfig(
|
let config = ApplycalConfig(
|
||||||
calibrationPath: "/tmp/cal.cal",
|
calibrationPath: "/tmp/cal.cal",
|
||||||
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
|
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
|
||||||
)
|
)
|
||||||
let args = try ApplycalArgs.build(config: config)
|
let args = try ApplycalArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
|
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnapplyEmittedWhenConfigSet() throws {
|
@Test("Unapply is emitted when the caller explicitly sets it")
|
||||||
|
func unapplyEmittedWhenConfigSet() throws {
|
||||||
let config = ApplycalConfig(
|
let config = ApplycalConfig(
|
||||||
calibrationPath: "/tmp/cal.cal",
|
calibrationPath: "/tmp/cal.cal",
|
||||||
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
|
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
|
||||||
@@ -22,6 +25,6 @@ final class ApplycalArgsTests: XCTestCase {
|
|||||||
let args = try ApplycalArgs.build(config: config)
|
let args = try ApplycalArgs.build(config: config)
|
||||||
// Builder emits -u only when the caller explicitly sets unapply.
|
// Builder emits -u only when the caller explicitly sets unapply.
|
||||||
// The UI layer never passes unapply: true in v2.0.
|
// The UI layer never passes unapply: true in v2.0.
|
||||||
XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
|
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
@testable import ICCeryCore
|
|
||||||
|
|
||||||
final class ArgsBuilderTests: XCTestCase {
|
|
||||||
|
|
||||||
// MARK: - option
|
|
||||||
|
|
||||||
func testOptionNil() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOptionPresent() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
|
|
||||||
XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
|
|
||||||
XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - optionIfNonEmpty
|
|
||||||
|
|
||||||
func testOptionIfNonEmptyNilEmpty() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOptionIfNonEmptyWhitespace() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOptionIfNonEmptyTrims() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - optionUnlessApprox
|
|
||||||
|
|
||||||
func testOptionUnlessApproxNil() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOptionUnlessApproxExactSkip() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOptionUnlessApproxWithinEpsilon() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOptionUnlessApproxOutsideEpsilon() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOptionUnlessApproxPOSIX() {
|
|
||||||
// 1234.5 must never produce a grouping separator or comma decimal.
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOptionUnlessApproxCustom() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
|
|
||||||
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - flag
|
|
||||||
|
|
||||||
func testFlagTrue() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
|
|
||||||
XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFlagFalse() {
|
|
||||||
XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
|
|
||||||
XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,13 @@ import Testing
|
|||||||
@Suite("ArgyllRunner Calibration")
|
@Suite("ArgyllRunner Calibration")
|
||||||
struct ArgyllRunnerCalibrationTests {
|
struct ArgyllRunnerCalibrationTests {
|
||||||
|
|
||||||
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
private func makeRunner() -> ArgyllRunner {
|
||||||
let binDir = URL(fileURLWithPath: #filePath)
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
||||||
return ArgyllRunner(
|
return ArgyllRunner(
|
||||||
processManager: processManager,
|
processManager: .shared,
|
||||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -44,9 +44,8 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
|
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
|
||||||
func calibrationTargenProcessId() async throws {
|
func calibrationTargenProcessId() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let pm = ProcessManager()
|
let runner = makeRunner()
|
||||||
let runner = makeRunner(processManager: pm)
|
let events = ProcessManager.shared.events()
|
||||||
let events = pm.events()
|
|
||||||
// Subscribed before spawn; the exit event is emitted before
|
// Subscribed before spawn; the exit event is emitted before
|
||||||
// runCalibrationTargen returns, so this always terminates.
|
// runCalibrationTargen returns, so this always terminates.
|
||||||
let sawExit = Task {
|
let sawExit = Task {
|
||||||
@@ -88,28 +87,10 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal failure throws toolFailed")
|
@Test("printcal failure throws printcalFailed")
|
||||||
func printcalFailureThrows() async throws {
|
func printcalFailureThrows() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
defer { try? FileManager.default.removeItem(at: testRoot) }
|
let runner = makeRunner()
|
||||||
|
|
||||||
// Per-test mock printcal that always fails — no global
|
|
||||||
// environment mutation, no shared fixture changes.
|
|
||||||
let binDir = try makeTestDir()
|
|
||||||
defer { try? FileManager.default.removeItem(at: binDir) }
|
|
||||||
let mockURL = binDir.appendingPathComponent("printcal")
|
|
||||||
try """
|
|
||||||
#!/bin/sh
|
|
||||||
echo "printcal mock failure" >&2
|
|
||||||
exit 1
|
|
||||||
""".write(to: mockURL, atomically: true, encoding: .utf8)
|
|
||||||
try FileManager.default.setAttributes(
|
|
||||||
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
|
||||||
|
|
||||||
let runner = ArgyllRunner(
|
|
||||||
processManager: ProcessManager(),
|
|
||||||
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)
|
|
||||||
)
|
|
||||||
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: "CAL_demo",
|
ti3Basename: "CAL_demo",
|
||||||
@@ -117,9 +98,12 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
outputURL: output
|
outputURL: output
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
|
||||||
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
|
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
|
||||||
|
|
||||||
|
await #expect(throws: (any Error).self) {
|
||||||
_ = try await runner.runPrintcal(config: config)
|
_ = try await runner.runPrintcal(config: config)
|
||||||
}
|
}
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ struct ArgyllRunnerColprofTests {
|
|||||||
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: .shared,
|
||||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,32 +49,4 @@ struct ArgyllRunnerColprofTests {
|
|||||||
|
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failing colprof throws toolFailed with code and logs")
|
|
||||||
func colprofFailureThrowsToolFailed() async throws {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
|
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
|
||||||
|
|
||||||
let mockURL = dir.appendingPathComponent("colprof")
|
|
||||||
try """
|
|
||||||
#!/bin/sh
|
|
||||||
echo "colprof broke" >&2
|
|
||||||
exit 4
|
|
||||||
""".write(to: mockURL, atomically: true, encoding: .utf8)
|
|
||||||
try FileManager.default.setAttributes(
|
|
||||||
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
|
||||||
|
|
||||||
let runner = ArgyllRunner(
|
|
||||||
processManager: ProcessManager(),
|
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)
|
|
||||||
)
|
|
||||||
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
|
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
|
||||||
tool: "colprof", code: 4, logs: ["colprof broke"])) {
|
|
||||||
try await runner.runColprof(config: config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import ICCeryCore
|
|
||||||
|
|
||||||
/// Focused contracts for the shared `runStreamingTool` loop (#79).
|
|
||||||
///
|
|
||||||
/// Every test uses a per-test temporary directory, unique basenames,
|
|
||||||
/// and a fresh `ProcessManager` — no shared UI fixture scripts and no
|
|
||||||
/// process-environment mutation.
|
|
||||||
@Suite("ArgyllRunner streaming loop contracts")
|
|
||||||
struct ArgyllRunnerStreamingLoopTests {
|
|
||||||
|
|
||||||
private func makeTempDir() throws -> URL {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("runner-loop-\(UUID().uuidString)")
|
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
private func writeMock(_ name: String, _ body: String, in dir: URL) throws {
|
|
||||||
let url = dir.appendingPathComponent(name)
|
|
||||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
|
||||||
try FileManager.default.setAttributes(
|
|
||||||
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeRunner(binDir: URL) -> ArgyllRunner {
|
|
||||||
ArgyllRunner(
|
|
||||||
processManager: ProcessManager(),
|
|
||||||
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines")
|
|
||||||
func nonZeroExitThrowsToolFailed() async throws {
|
|
||||||
let dir = try makeTempDir()
|
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
|
||||||
try writeMock("targen", """
|
|
||||||
#!/bin/sh
|
|
||||||
echo "Generating patches..."
|
|
||||||
echo "targen: too few patches" >&2
|
|
||||||
exit 3
|
|
||||||
""", in: dir)
|
|
||||||
let runner = makeRunner(binDir: dir)
|
|
||||||
let config = TargenConfig(
|
|
||||||
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
|
||||||
blackPatches: 4, basename: "fail", workingDirectory: dir)
|
|
||||||
|
|
||||||
do {
|
|
||||||
_ = try await runner.runTargen(config: config)
|
|
||||||
Issue.record("Expected toolFailed")
|
|
||||||
} catch let error as ArgyllRunnerError {
|
|
||||||
guard case .toolFailed(let tool, let code, let logs) = error else {
|
|
||||||
Issue.record("Expected toolFailed, got \(error)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(tool == "targen")
|
|
||||||
#expect(code == 3)
|
|
||||||
#expect(logs.contains("Generating patches..."))
|
|
||||||
#expect(logs.contains("targen: too few patches"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path")
|
|
||||||
func zeroExitMissingArtefact() async throws {
|
|
||||||
let dir = try makeTempDir()
|
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
|
||||||
try writeMock("targen", """
|
|
||||||
#!/bin/sh
|
|
||||||
echo "done but wrote nothing"
|
|
||||||
exit 0
|
|
||||||
""", in: dir)
|
|
||||||
let runner = makeRunner(binDir: dir)
|
|
||||||
let expectedPath = dir.appendingPathComponent("gone.ti1").path
|
|
||||||
let config = TargenConfig(
|
|
||||||
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
|
||||||
blackPatches: 4, basename: "gone", workingDirectory: dir)
|
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) {
|
|
||||||
try await runner.runTargen(config: config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Immediate exit after one stdout line still delivers the line and succeeds")
|
|
||||||
func immediateExitDeliversLine() async throws {
|
|
||||||
let dir = try makeTempDir()
|
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
|
||||||
try writeMock("targen", """
|
|
||||||
#!/bin/sh
|
|
||||||
last=""
|
|
||||||
for arg in "$@"; do last="$arg"; done
|
|
||||||
echo "only line"
|
|
||||||
touch "$last.ti1"
|
|
||||||
exit 0
|
|
||||||
""", in: dir)
|
|
||||||
let runner = makeRunner(binDir: dir)
|
|
||||||
let config = TargenConfig(
|
|
||||||
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
|
||||||
blackPatches: 4, basename: "quick", workingDirectory: dir)
|
|
||||||
|
|
||||||
let holder = LogHolder()
|
|
||||||
let url = try await runner.runTargen(config: config) { batch in
|
|
||||||
holder.append(batch)
|
|
||||||
}
|
|
||||||
#expect(url.lastPathComponent == "quick.ti1")
|
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
|
||||||
#expect(holder.lines.contains("only line"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("colprof unterminated progress fragment reaches onLogBatch before exit")
|
|
||||||
func colprofPartialLineFlush() async throws {
|
|
||||||
let dir = try makeTempDir()
|
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
|
||||||
// The fragment is printed without a newline, then the mock sleeps
|
|
||||||
// past the 500 ms partial-line flush interval before writing the
|
|
||||||
// artefact and exiting — so the tail is delivered mid-run.
|
|
||||||
try writeMock("colprof", """
|
|
||||||
#!/bin/sh
|
|
||||||
last=""
|
|
||||||
for arg in "$@"; do last="$arg"; done
|
|
||||||
printf 'Doing gamut mapping'
|
|
||||||
sleep 2
|
|
||||||
touch "$last.icc"
|
|
||||||
exit 0
|
|
||||||
""", in: dir)
|
|
||||||
let runner = makeRunner(binDir: dir)
|
|
||||||
let config = ColprofConfig(basename: "frag", workingDirectory: dir)
|
|
||||||
|
|
||||||
let holder = LogHolder()
|
|
||||||
let url = try await runner.runColprof(config: config) { batch in
|
|
||||||
holder.append(batch)
|
|
||||||
}
|
|
||||||
#expect(url.lastPathComponent == "frag.icc")
|
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
|
||||||
#expect(holder.lines.contains("Doing gamut mapping"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("toolFailed maps each tool to its user-facing description",
|
|
||||||
arguments: [
|
|
||||||
(tool: "chartread", expected: "Chartread failed: boom"),
|
|
||||||
(tool: "average", expected: "Averaging failed: boom"),
|
|
||||||
(tool: "colprof", expected: "Profile creation failed: boom"),
|
|
||||||
(tool: "printcal", expected: "Calibration curve computation failed: boom"),
|
|
||||||
(tool: "applycal", expected: "Apply calibration failed: boom"),
|
|
||||||
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
|
|
||||||
(tool: "profcheck", expected: "Profile verification failed: boom"),
|
|
||||||
])
|
|
||||||
func toolDescriptions(tool: String, expected: String) {
|
|
||||||
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
|
|
||||||
#expect(error.errorDescription == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("toolFailed falls back to a generic description for unmapped tools and empty logs")
|
|
||||||
func genericFallbacks() {
|
|
||||||
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
|
|
||||||
#expect(unknown.errorDescription == "Process exited with code 7")
|
|
||||||
|
|
||||||
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
|
|
||||||
#expect(emptyLogs.errorDescription
|
|
||||||
== "Profile creation failed: exited with code 2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
@@ -10,8 +10,9 @@ private func tempURL(_ name: String) -> URL {
|
|||||||
.appendingPathComponent(name)
|
.appendingPathComponent(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Ti2HeaderTests: XCTestCase {
|
@Suite("Ti2Header")
|
||||||
func testParsesKeywordsAndSibling() throws {
|
struct Ti2HeaderTests {
|
||||||
|
@Test func parsesKeywordsAndSibling() throws {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
@@ -30,18 +31,18 @@ final class Ti2HeaderTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
|
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
|
||||||
XCTAssertEqual(h.instrument, "i1iO")
|
#expect(h.instrument == "i1iO")
|
||||||
XCTAssertEqual(h.patchCount, 800)
|
#expect(h.patchCount == 800)
|
||||||
XCTAssertEqual(h.pageCount, 3)
|
#expect(h.pageCount == 3)
|
||||||
XCTAssertTrue(h.hasSiblingTi1)
|
#expect(h.hasSiblingTi1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMissingFileYieldsEmptyHeader() {
|
@Test func missingFileYieldsEmptyHeader() {
|
||||||
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
|
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
|
||||||
XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNumberOfFieldsIsNotPatchCount() throws {
|
@Test func numberOfFieldsIsNotPatchCount() throws {
|
||||||
let url = tempURL("t.ti2")
|
let url = tempURL("t.ti2")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
@@ -49,11 +50,12 @@ final class Ti2HeaderTests: XCTestCase {
|
|||||||
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
|
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
|
||||||
to: url, atomically: true, encoding: .utf8
|
to: url, atomically: true, encoding: .utf8
|
||||||
)
|
)
|
||||||
XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
|
#expect(Ti2Header.parse(url).patchCount == 52)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class TiffPreviewTests: XCTestCase {
|
@Suite("TiffPreview")
|
||||||
|
struct TiffPreviewTests {
|
||||||
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
|
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
|
||||||
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
|
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
|
||||||
let url = tempURL("big.tif")
|
let url = tempURL("big.tif")
|
||||||
@@ -79,48 +81,50 @@ final class TiffPreviewTests: XCTestCase {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
func testProducesCappedPNG() throws {
|
@Test func producesCappedPNG() throws {
|
||||||
let tiff = try makeTiff()
|
let tiff = try makeTiff()
|
||||||
let png = TiffPreview.previewPNG(tiff: tiff)
|
let png = TiffPreview.previewPNG(tiff: tiff)
|
||||||
XCTAssertNotNil(png)
|
#expect(png != nil)
|
||||||
// PNG magic
|
// PNG magic
|
||||||
XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
|
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
|
||||||
// Verify the cap by decoding the thumbnail header.
|
// Verify the cap by decoding the thumbnail header.
|
||||||
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
|
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
|
||||||
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
|
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
|
||||||
XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
|
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
|
||||||
XCTAssertEqual(img.width, 1200)
|
#expect(img.width == 1200)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNonTiffReturnsNil() throws {
|
@Test func nonTiffReturnsNil() throws {
|
||||||
let url = tempURL("not-tiff.txt")
|
let url = tempURL("not-tiff.txt")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||||
XCTAssertNil(TiffPreview.previewPNG(tiff: url))
|
#expect(TiffPreview.previewPNG(tiff: url) == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ArtefactFilesTests: XCTestCase {
|
@Suite("ArtefactFiles")
|
||||||
func testBase64RoundTrip() throws {
|
struct ArtefactFilesTests {
|
||||||
|
@Test func base64RoundTrip() throws {
|
||||||
let url = tempURL("a.txt")
|
let url = tempURL("a.txt")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||||
let b64 = try ArtefactFiles.readBase64(url)
|
let b64 = try ArtefactFiles.readBase64(url)
|
||||||
XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
|
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDefaultWorkingDirExists() {
|
@Test func defaultWorkingDirExists() {
|
||||||
XCTAssertTrue(FileManager.default.fileExists(
|
#expect(FileManager.default.fileExists(
|
||||||
atPath: ArtefactFiles.defaultWorkingDirectory().path
|
atPath: ArtefactFiles.defaultWorkingDirectory().path
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ArtefactProbeProfileTests: XCTestCase {
|
@Suite("ArtefactProbe profile resolve")
|
||||||
|
struct ArtefactProbeProfileTests {
|
||||||
private func makeDir() throws -> URL {
|
private func makeDir() throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
.appendingPathComponent("probe-\(UUID().uuidString)")
|
||||||
@@ -130,83 +134,93 @@ final class ArtefactProbeProfileTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: Basename probe matrix (#69)
|
// MARK: Basename probe matrix (#69)
|
||||||
|
|
||||||
func testOnlyIcc() throws {
|
@Test("basename probe: only .icc exists")
|
||||||
|
func onlyIcc() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
let icc = dir.appendingPathComponent("job.icc")
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
try Data("icc".utf8).write(to: icc)
|
try Data("icc".utf8).write(to: icc)
|
||||||
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path)
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOnlyIcm() throws {
|
@Test("basename probe: only .icm exists")
|
||||||
|
func onlyIcm() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
let icm = dir.appendingPathComponent("job.icm")
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
try Data("icm".utf8).write(to: icm)
|
try Data("icm".utf8).write(to: icm)
|
||||||
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path)
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testIcmWins() throws {
|
@Test("basename probe prefers .icm")
|
||||||
|
func icmWins() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||||
let icm = dir.appendingPathComponent("job.icm")
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
try Data("icm".utf8).write(to: icm)
|
try Data("icm".utf8).write(to: icm)
|
||||||
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
||||||
XCTAssertEqual(url?.path, icm.path)
|
#expect(url?.path == icm.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNeitherExists() throws {
|
@Test("basename probe: neither exists returns nil")
|
||||||
|
func neitherExists() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir))
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Explicit URL matrix (#69 / #83)
|
// MARK: Explicit URL matrix (#69 / #83)
|
||||||
|
|
||||||
func testExplicitIccWins() throws {
|
@Test("explicit existing .icc wins even when .icm exists")
|
||||||
|
func explicitIccWins() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
let icc = dir.appendingPathComponent("job.icc")
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
try Data("icc".utf8).write(to: icc)
|
try Data("icc".utf8).write(to: icc)
|
||||||
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
||||||
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
|
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExplicitIcmWins() throws {
|
@Test("explicit existing .icm wins even when .icc exists")
|
||||||
|
func explicitIcmWins() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||||
let icm = dir.appendingPathComponent("job.icm")
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
try Data("icm".utf8).write(to: icm)
|
try Data("icm".utf8).write(to: icm)
|
||||||
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path)
|
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFlipExtension() throws {
|
@Test("explicit missing .icc flips to sibling .icm")
|
||||||
|
func flipExtension() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
let icc = dir.appendingPathComponent("job.icc")
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
let icm = dir.appendingPathComponent("job.icm")
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
try Data("icm".utf8).write(to: icm)
|
try Data("icm".utf8).write(to: icm)
|
||||||
let resolved = ArtefactProbe.resolveProfile(icc)
|
let resolved = ArtefactProbe.resolveProfile(icc)
|
||||||
XCTAssertEqual(resolved.path, icm.path)
|
#expect(resolved.path == icm.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFlipToIcc() throws {
|
@Test("explicit missing .icm flips to sibling .icc")
|
||||||
|
func flipToIcc() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
let icc = dir.appendingPathComponent("job.icc")
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
let icm = dir.appendingPathComponent("job.icm")
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
try Data("icc".utf8).write(to: icc)
|
try Data("icc".utf8).write(to: icc)
|
||||||
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path)
|
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMissingBoth() throws {
|
@Test("explicit missing both returns the original URL")
|
||||||
|
func missingBoth() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
let icc = dir.appendingPathComponent("job.icc")
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
|
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnrelatedExtension() throws {
|
@Test("unrelated extension is never rewritten")
|
||||||
|
func unrelatedExtension() throws {
|
||||||
let dir = try makeDir()
|
let dir = try makeDir()
|
||||||
let mpp = dir.appendingPathComponent("job.mpp")
|
let mpp = dir.appendingPathComponent("job.mpp")
|
||||||
let icc = dir.appendingPathComponent("job.icc")
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
try Data("icc".utf8).write(to: icc)
|
try Data("icc".utf8).write(to: icc)
|
||||||
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
|
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
|
||||||
XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path)
|
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path)
|
||||||
let txt = dir.appendingPathComponent("job.txt")
|
let txt = dir.appendingPathComponent("job.txt")
|
||||||
XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path)
|
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class BinaryResolverTests: XCTestCase {
|
@Suite("BinaryResolver")
|
||||||
|
struct BinaryResolverTests {
|
||||||
|
|
||||||
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
@@ -21,16 +22,16 @@ final class BinaryResolverTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOverrideDirWinsWhenFileExists() throws {
|
@Test func overrideDirWinsWhenFileExists() throws {
|
||||||
let override = try makeTree { root in
|
let override = try makeTree { root in
|
||||||
try touch(root.appendingPathComponent("targen"))
|
try touch(root.appendingPathComponent("targen"))
|
||||||
}
|
}
|
||||||
let bundled = try makeTree { _ in }
|
let bundled = try makeTree { _ in }
|
||||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||||
XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen"))
|
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOverrideFallsThroughWhenMissing() throws {
|
@Test func overrideFallsThroughWhenMissing() throws {
|
||||||
let override = try makeTree { _ in }
|
let override = try makeTree { _ in }
|
||||||
let bundled = try makeTree { root in
|
let bundled = try makeTree { root in
|
||||||
let dir = root.appendingPathComponent("macos-universal")
|
let dir = root.appendingPathComponent("macos-universal")
|
||||||
@@ -38,10 +39,10 @@ final class BinaryResolverTests: XCTestCase {
|
|||||||
try touch(dir.appendingPathComponent("instlist"))
|
try touch(dir.appendingPathComponent("instlist"))
|
||||||
}
|
}
|
||||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||||
XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen"))
|
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUniversalPreferredWhenMarkerPresent() throws {
|
@Test func universalPreferredWhenMarkerPresent() throws {
|
||||||
let bundled = try makeTree { root in
|
let bundled = try makeTree { root in
|
||||||
for dir in ["macos-universal", "macos-x86_64"] {
|
for dir in ["macos-universal", "macos-x86_64"] {
|
||||||
let d = root.appendingPathComponent(dir)
|
let d = root.appendingPathComponent(dir)
|
||||||
@@ -50,10 +51,10 @@ final class BinaryResolverTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let r = BinaryResolver(bundledRoot: bundled)
|
let r = BinaryResolver(bundledRoot: bundled)
|
||||||
XCTAssertEqual(r.platformDir(), "macos-universal")
|
#expect(r.platformDir() == "macos-universal")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFallsBackToArchDir() throws {
|
@Test func fallsBackToArchDir() throws {
|
||||||
let bundled = try makeTree { root in
|
let bundled = try makeTree { root in
|
||||||
let d = root.appendingPathComponent("macos-x86_64")
|
let d = root.appendingPathComponent("macos-x86_64")
|
||||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||||
@@ -63,20 +64,20 @@ final class BinaryResolverTests: XCTestCase {
|
|||||||
bundledRoot: bundled,
|
bundledRoot: bundled,
|
||||||
archDirs: ["macos-universal", "macos-x86_64"]
|
archDirs: ["macos-universal", "macos-x86_64"]
|
||||||
)
|
)
|
||||||
XCTAssertEqual(r.platformDir(), "macos-x86_64")
|
#expect(r.platformDir() == "macos-x86_64")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMissingEverythingReturnsConstructedPath() throws {
|
@Test func missingEverythingReturnsConstructedPath() throws {
|
||||||
let bundled = try makeTree { _ in }
|
let bundled = try makeTree { _ in }
|
||||||
let r = BinaryResolver(bundledRoot: bundled)
|
let r = BinaryResolver(bundledRoot: bundled)
|
||||||
// v1 semantic: path is returned; spawn surfaces the error.
|
// v1 semantic: path is returned; spawn surfaces the error.
|
||||||
XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
||||||
XCTAssertFalse(r.exists(r.resolve("targen")))
|
#expect(!r.exists(r.resolve("targen")))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMockAndGamutPaths() throws {
|
@Test func mockAndGamutPaths() throws {
|
||||||
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
||||||
XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
|
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
|
||||||
XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
|
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class CGATSParserTests: XCTestCase {
|
@Suite("CGATS Parser & Writer")
|
||||||
|
struct CGATSParserTests {
|
||||||
|
|
||||||
private static let canonicalCTI3 = """
|
private static let canonicalCTI3 = """
|
||||||
CTI3
|
CTI3
|
||||||
@@ -20,40 +21,44 @@ final class CGATSParserTests: XCTestCase {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
|
|
||||||
func testParseCTI3() throws {
|
@Test("Parses CTI3 with canonical field names")
|
||||||
|
func parseCTI3() throws {
|
||||||
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
XCTAssertEqual(dataset.format, .cti3)
|
#expect(dataset.format == .cti3)
|
||||||
XCTAssertEqual(dataset.samples.count, 2)
|
#expect(dataset.samples.count == 2)
|
||||||
XCTAssertEqual(dataset.colorRep, "RGB")
|
#expect(dataset.colorRep == "RGB")
|
||||||
XCTAssertEqual(dataset.deviceClass, "DISPLAY")
|
#expect(dataset.deviceClass == "DISPLAY")
|
||||||
XCTAssertEqual(dataset.samples[0].id, "1")
|
#expect(dataset.samples[0].id == "1")
|
||||||
XCTAssertEqual(dataset.samples[0].loc, "A1")
|
#expect(dataset.samples[0].loc == "A1")
|
||||||
XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
|
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRoundTrip() throws {
|
@Test("Round-trips parse, write, reparse")
|
||||||
|
func roundTrip() throws {
|
||||||
let first = try CGATSParser.parse(Self.canonicalCTI3)
|
let first = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
let text = try CGATSWriter.write(first)
|
let text = try CGATSWriter.write(first)
|
||||||
let second = try CGATSParser.parse(text)
|
let second = try CGATSParser.parse(text)
|
||||||
XCTAssertEqual(second.format, first.format)
|
#expect(second.format == first.format)
|
||||||
XCTAssertEqual(second.samples.count, first.samples.count)
|
#expect(second.samples.count == first.samples.count)
|
||||||
XCTAssertEqual(second.colorRep, first.colorRep)
|
#expect(second.colorRep == first.colorRep)
|
||||||
XCTAssertEqual(second.deviceClass, first.deviceClass)
|
#expect(second.deviceClass == first.deviceClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testParseCSV() throws {
|
@Test("Parses CSV with comma delimiters")
|
||||||
|
func parseCSV() throws {
|
||||||
let csv = """
|
let csv = """
|
||||||
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
|
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
|
||||||
1,A1,50,0,0,20,10,5,50,60,30
|
1,A1,50,0,0,20,10,5,50,60,30
|
||||||
2,A2,0,50,0,10,30,5,60,-50,40
|
2,A2,0,50,0,10,30,5,60,-50,40
|
||||||
"""
|
"""
|
||||||
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
|
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
|
||||||
XCTAssertEqual(dataset.format, .csv)
|
#expect(dataset.format == .csv)
|
||||||
XCTAssertEqual(dataset.samples.count, 2)
|
#expect(dataset.samples.count == 2)
|
||||||
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
|
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testConverts255To100() throws {
|
@Test("Converts 0-255 device values to 0-100")
|
||||||
|
func converts255To100() throws {
|
||||||
let rgb = """
|
let rgb = """
|
||||||
CTI3
|
CTI3
|
||||||
COLOR_REP RGB
|
COLOR_REP RGB
|
||||||
@@ -67,11 +72,12 @@ final class CGATSParserTests: XCTestCase {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
let dataset = try CGATSParser.parse(rgb)
|
let dataset = try CGATSParser.parse(rgb)
|
||||||
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
|
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
|
||||||
XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
|
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSynthesizesMetadata() throws {
|
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
|
||||||
|
func synthesizesMetadata() throws {
|
||||||
let cmyk = """
|
let cmyk = """
|
||||||
CTI3
|
CTI3
|
||||||
NUMBER_OF_FIELDS 6
|
NUMBER_OF_FIELDS 6
|
||||||
@@ -84,15 +90,19 @@ final class CGATSParserTests: XCTestCase {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
let dataset = try CGATSParser.parse(cmyk)
|
let dataset = try CGATSParser.parse(cmyk)
|
||||||
XCTAssertEqual(dataset.colorRep, "CMYK")
|
#expect(dataset.colorRep == "CMYK")
|
||||||
XCTAssertEqual(dataset.deviceClass, "PRINTER")
|
#expect(dataset.deviceClass == "PRINTER")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRejectsEmpty() {
|
@Test("Rejects empty file")
|
||||||
XCTAssertThrowsError(try CGATSParser.parse(""))
|
func rejectsEmpty() {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CGATSParser.parse("")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRejectsArity() {
|
@Test("Rejects malformed arity")
|
||||||
|
func rejectsArity() {
|
||||||
let bad = """
|
let bad = """
|
||||||
CTI3
|
CTI3
|
||||||
NUMBER_OF_FIELDS 2
|
NUMBER_OF_FIELDS 2
|
||||||
@@ -104,18 +114,21 @@ final class CGATSParserTests: XCTestCase {
|
|||||||
1
|
1
|
||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
XCTAssertThrowsError(try CGATSParser.parse(bad))
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CGATSParser.parse(bad)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWriterFormat() throws {
|
@Test("Writer emits valid .ti3 with tabs and required keywords")
|
||||||
|
func writerFormat() throws {
|
||||||
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
let text = try CGATSWriter.write(dataset)
|
let text = try CGATSWriter.write(dataset)
|
||||||
XCTAssertTrue(text.contains("CTI3"))
|
#expect(text.contains("CTI3"))
|
||||||
XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
|
#expect(text.contains("BEGIN_DATA_FORMAT"))
|
||||||
XCTAssertTrue(text.contains("BEGIN_DATA"))
|
#expect(text.contains("BEGIN_DATA"))
|
||||||
XCTAssertTrue(text.contains("END_DATA"))
|
#expect(text.contains("END_DATA"))
|
||||||
XCTAssertTrue(text.contains("COLOR_REP"))
|
#expect(text.contains("COLOR_REP"))
|
||||||
XCTAssertTrue(text.contains("DEVICE_CLASS"))
|
#expect(text.contains("DEVICE_CLASS"))
|
||||||
XCTAssertTrue(text.contains("\t"))
|
#expect(text.contains("\t"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,78 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Issue #83 — canonical `CAL_` / original-stem pairing.
|
/// Issue #83 — canonical `CAL_` / original-stem pairing.
|
||||||
final class CalibrationIdentityTests: XCTestCase {
|
@Suite("CalibrationIdentity")
|
||||||
func testLivePlain() {
|
struct CalibrationIdentityTests {
|
||||||
|
@Test("live foo, no persisted")
|
||||||
|
func livePlain() {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
||||||
XCTAssertEqual(id.originalBasename, "foo")
|
#expect(id.originalBasename == "foo")
|
||||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLivePlainIgnoresPersisted() {
|
@Test("live foo ignores stale persisted")
|
||||||
|
func livePlainIgnoresPersisted() {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
|
||||||
XCTAssertEqual(id.originalBasename, "foo")
|
#expect(id.originalBasename == "foo")
|
||||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLiveCalPersisted() {
|
@Test("live CAL_foo, persisted foo")
|
||||||
|
func liveCalPersisted() {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
||||||
XCTAssertEqual(id.originalBasename, "foo")
|
#expect(id.originalBasename == "foo")
|
||||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLiveCalNoPersist() {
|
@Test("live CAL_foo, empty persisted strips prefix")
|
||||||
|
func liveCalNoPersist() {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
||||||
XCTAssertEqual(id.originalBasename, "foo")
|
#expect(id.originalBasename == "foo")
|
||||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPersistedWins() {
|
@Test("persisted original wins over CAL_ live")
|
||||||
|
func persistedWins() {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
||||||
XCTAssertEqual(id.originalBasename, "bar")
|
#expect(id.originalBasename == "bar")
|
||||||
XCTAssertEqual(id.calibrationBasename, "CAL_bar")
|
#expect(id.calibrationBasename == "CAL_bar")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEmptyLiveWithPersisted() {
|
@Test("empty live yields empty identity even with persisted original")
|
||||||
|
func emptyLiveWithPersisted() {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
|
||||||
XCTAssertTrue(id.originalBasename.isEmpty)
|
#expect(id.originalBasename.isEmpty)
|
||||||
XCTAssertTrue(id.calibrationBasename.isEmpty)
|
#expect(id.calibrationBasename.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEmptyLive() {
|
@Test("empty live, empty persisted")
|
||||||
|
func emptyLive() {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
||||||
XCTAssertTrue(id.originalBasename.isEmpty)
|
#expect(id.originalBasename.isEmpty)
|
||||||
XCTAssertTrue(id.calibrationBasename.isEmpty)
|
#expect(id.calibrationBasename.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAlreadyPrefixed() {
|
@Test("prefix is idempotent on already-prefixed input")
|
||||||
XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
|
func alreadyPrefixed() {
|
||||||
XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo")
|
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
|
||||||
|
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
|
||||||
XCTAssertEqual(id.originalBasename, "CAL_foo")
|
#expect(id.originalBasename == "CAL_foo")
|
||||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPrefixEmpty() {
|
@Test("prefix never invents a name from empty input")
|
||||||
XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
|
func prefixEmpty() {
|
||||||
XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
|
#expect(CalibrationIdentity.prefix("").isEmpty)
|
||||||
XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
|
#expect(CalibrationIdentity.strip("foo") == "foo")
|
||||||
|
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testProcessIdMatches() {
|
@Test("runner process id for a calibration targen is targen_CAL_*")
|
||||||
|
func processIdMatches() {
|
||||||
let cal = CalibrationIdentity.prefix("foo")
|
let cal = CalibrationIdentity.prefix("foo")
|
||||||
XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
|
#expect(ProcessID.targen(cal) == "targen_CAL_foo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class CalibrationTargenArgsTests: XCTestCase {
|
@Suite("CalibrationTargenArgs")
|
||||||
|
struct CalibrationTargenArgsTests {
|
||||||
|
|
||||||
func testRgbBaseline() throws {
|
@Test("RGB baseline")
|
||||||
|
func rgbBaseline() throws {
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
steps: 21,
|
steps: 21,
|
||||||
@@ -13,10 +15,11 @@ final class CalibrationTargenArgsTests: XCTestCase {
|
|||||||
workingDirectory: URL(fileURLWithPath: "/tmp")
|
workingDirectory: URL(fileURLWithPath: "/tmp")
|
||||||
)
|
)
|
||||||
let args = try CalibrationTargenArgs.build(config: config)
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
|
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCmykWithOptions() throws {
|
@Test("CMYK baseline with ink limit and neutral emphasis")
|
||||||
|
func cmykWithOptions() throws {
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: .cmyk,
|
colourSpace: .cmyk,
|
||||||
steps: 25,
|
steps: 25,
|
||||||
@@ -27,26 +30,33 @@ final class CalibrationTargenArgsTests: XCTestCase {
|
|||||||
workingDirectory: URL(fileURLWithPath: "/tmp")
|
workingDirectory: URL(fileURLWithPath: "/tmp")
|
||||||
)
|
)
|
||||||
let args = try CalibrationTargenArgs.build(config: config)
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
|
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRejectsBadSteps() {
|
@Test("Rejects out-of-range steps")
|
||||||
|
func rejectsBadSteps() {
|
||||||
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
|
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
|
||||||
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CalibrationTargenArgs.build(config: config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRejectsBadInkLimit() {
|
@Test("Rejects bad CMYK ink limit")
|
||||||
|
func rejectsBadInkLimit() {
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: .cmyk,
|
colourSpace: .cmyk,
|
||||||
inkLimit: 500,
|
inkLimit: 500,
|
||||||
basename: "demo"
|
basename: "demo"
|
||||||
)
|
)
|
||||||
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CalibrationTargenArgs.build(config: config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNoDoublePrefix() throws {
|
@Test("Does not double-prefix an existing CAL_ basename")
|
||||||
|
func noDoublePrefix() throws {
|
||||||
let config = CalibrationTargenConfig(basename: "CAL_test")
|
let config = CalibrationTargenConfig(basename: "CAL_test")
|
||||||
let args = try CalibrationTargenArgs.build(config: config)
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
XCTAssertEqual(args.last, "CAL_test")
|
#expect(args.last == "CAL_test")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,72 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class ColprofArgsTests: XCTestCase {
|
@Suite("ColprofArgs")
|
||||||
|
struct ColprofArgsTests {
|
||||||
|
|
||||||
func testDefaults() throws {
|
@Test("Default algorithm and quality")
|
||||||
|
func defaults() throws {
|
||||||
let config = ColprofConfig(basename: "target")
|
let config = ColprofConfig(basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"])
|
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFwaBareFlag() throws {
|
@Test("FWA bare -f when empty string")
|
||||||
|
func fwaBareFlag() throws {
|
||||||
let config = ColprofConfig(fwa: "", basename: "target")
|
let config = ColprofConfig(fwa: "", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"])
|
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFwaD50() throws {
|
@Test("FWA D50 and D65 emit -f value")
|
||||||
|
func fwaD50() throws {
|
||||||
let config = ColprofConfig(fwa: "D50", basename: "target")
|
let config = ColprofConfig(fwa: "D50", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
XCTAssertTrue(args.contains("-f"))
|
#expect(args.contains("-f"))
|
||||||
XCTAssertTrue(args.contains("D50"))
|
#expect(args.contains("D50"))
|
||||||
XCTAssertEqual(args.last, "target")
|
#expect(args.last == "target")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFwaNoneOmitted() throws {
|
@Test("FWA none is omitted")
|
||||||
|
func fwaNoneOmitted() throws {
|
||||||
let config = ColprofConfig(fwa: "none", basename: "target")
|
let config = ColprofConfig(fwa: "none", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("-f"))
|
#expect(!args.contains("-f"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testViewingCondNoneSkipped() throws {
|
@Test("Viewing conditions skip none")
|
||||||
|
func viewingCondNoneSkipped() throws {
|
||||||
let config = ColprofConfig(
|
let config = ColprofConfig(
|
||||||
inputViewingCond: "none",
|
inputViewingCond: "none",
|
||||||
outputViewingCond: "mt",
|
outputViewingCond: "mt",
|
||||||
basename: "target"
|
basename: "target"
|
||||||
)
|
)
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("-c"))
|
#expect(!args.contains("-c"))
|
||||||
XCTAssertTrue(args.contains("-d"))
|
#expect(args.contains("-d"))
|
||||||
XCTAssertTrue(args.contains("mt"))
|
#expect(args.contains("mt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDescriptionFallback() throws {
|
@Test("Description falls back to basename when empty")
|
||||||
|
func descriptionFallback() throws {
|
||||||
let config = ColprofConfig(description: "", basename: "target")
|
let config = ColprofConfig(description: "", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("-D"))
|
#expect(!args.contains("-D"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCopyright() throws {
|
@Test("Copyright only when non-empty")
|
||||||
|
func copyright() throws {
|
||||||
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
|
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
XCTAssertTrue(args.contains("-C"))
|
#expect(args.contains("-C"))
|
||||||
XCTAssertTrue(args.contains("Gronod 2026"))
|
#expect(args.contains("Gronod 2026"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNoProgressJsonFlag() throws {
|
@Test("No -u passed")
|
||||||
|
func noProgressJsonFlag() throws {
|
||||||
let config = ColprofConfig(basename: "target")
|
let config = ColprofConfig(basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("-u"))
|
#expect(!args.contains("-u"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class ColprofProgressTests: XCTestCase {
|
@Suite("ColprofProgress")
|
||||||
|
struct ColprofProgressTests {
|
||||||
|
|
||||||
func testGamutMapping() {
|
@Test("Classifies gamut mapping")
|
||||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
|
func gamutMapping() {
|
||||||
|
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFitting() {
|
@Test("Classifies fitting or clut")
|
||||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
|
func fitting() {
|
||||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
|
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
|
||||||
|
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWriting() {
|
@Test("Classifies writing")
|
||||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
|
func writing() {
|
||||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
|
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
|
||||||
|
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
@@ -6,42 +6,48 @@ import AppKit
|
|||||||
import ApplicationServices
|
import ApplicationServices
|
||||||
|
|
||||||
/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥).
|
/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥).
|
||||||
final class CupsOptionsFilterTests: XCTestCase {
|
@Suite("CupsOptionsFilter")
|
||||||
|
struct CupsOptionsFilterTests {
|
||||||
|
|
||||||
func testDropsReserved() {
|
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
|
||||||
|
func dropsReserved() {
|
||||||
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
|
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
|
||||||
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
|
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
|
||||||
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
|
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
|
||||||
+ "collate=true copies=1 job-sheets=none,none "
|
+ "collate=true copies=1 job-sheets=none,none "
|
||||||
+ "pserrorhandler-requested=standard "
|
+ "pserrorhandler-requested=standard "
|
||||||
+ "MediaType=PhotographicGlossy"
|
+ "MediaType=PhotographicGlossy"
|
||||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
|
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testKeepsRelevant() {
|
@Test("Keeps relevant driver keys, order preserved")
|
||||||
|
func keepsRelevant() {
|
||||||
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
|
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
|
||||||
+ "Resolution=600x600dpi Duplex=None"
|
+ "Resolution=600x600dpi Duplex=None"
|
||||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
#expect(CupsOptionsFilter.filter(raw) == raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testKeepsUnknown() {
|
@Test("Permissive: unknown non-com.* keys survive")
|
||||||
|
func keepsUnknown() {
|
||||||
let raw = "VendorFooBar=baz MediaType=Plain"
|
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
#expect(CupsOptionsFilter.filter(raw) == raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDropsEmpty() {
|
@Test("Drops empty keys and values")
|
||||||
|
func dropsEmpty() {
|
||||||
let raw = "=noval MediaType= InputSlot=Rear"
|
let raw = "=noval MediaType= InputSlot=Rear"
|
||||||
// "MediaType=" has an empty value → dropped; "=noval" empty key.
|
// "MediaType=" has an empty value → dropped; "=noval" empty key.
|
||||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear")
|
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExtractMedia() {
|
@Test("extractMediaType prefers MediaType then EPIJ_Medi")
|
||||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
func extractMedia() {
|
||||||
fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
|
#expect(CupsParsers.extractMediaType(
|
||||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
|
||||||
fromOptionsString: "EPIJ_Medi=7"), "7")
|
#expect(CupsParsers.extractMediaType(
|
||||||
XCTAssertNil(CupsParsers.extractMediaType(
|
fromOptionsString: "EPIJ_Medi=7") == "7")
|
||||||
fromOptionsString: "PageSize=A4"))
|
#expect(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "PageSize=A4") == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +55,9 @@ final class CupsOptionsFilterTests: XCTestCase {
|
|||||||
/// `@convention(c)` closures can't capture, so recording goes through
|
/// `@convention(c)` closures can't capture, so recording goes through
|
||||||
/// a file-scope recorder keyed by global state; no private symbols are
|
/// a file-scope recorder keyed by global state; no private symbols are
|
||||||
/// touched.
|
/// touched.
|
||||||
|
@Suite("ColorSyncSuppressor")
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ColorSyncSuppressorTests: XCTestCase {
|
struct ColorSyncSuppressorTests {
|
||||||
|
|
||||||
/// Fake PMPrintSession — the injected resolver never dereferences it.
|
/// Fake PMPrintSession — the injected resolver never dereferences it.
|
||||||
private var fakeSession: PMPrintSession {
|
private var fakeSession: PMPrintSession {
|
||||||
@@ -83,50 +90,55 @@ final class ColorSyncSuppressorTests: XCTestCase {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAttemptOrder() {
|
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
|
||||||
|
func attemptOrder() {
|
||||||
Self.recorded = []
|
Self.recorded = []
|
||||||
Self.succeeding = nil
|
Self.succeeding = nil
|
||||||
Self.missing = ["PMSessionSetColorMatchingModeLock"]
|
Self.missing = ["PMSessionSetColorMatchingModeLock"]
|
||||||
let s = makeSuppressor()
|
let s = makeSuppressor()
|
||||||
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
|
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||||
// Lock is unresolvable → skipped; the rest plays out in order.
|
// Lock is unresolvable → skipped; the rest plays out in order.
|
||||||
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
|
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
|
||||||
|
== ColorMatchingAttempts.attempts
|
||||||
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
|
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
|
||||||
.map { "\($0.symbol)|\($0.mode)" })
|
.map { "\($0.symbol)|\($0.mode)" })
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFirstZeroWins() {
|
@Test("First zero wins — later symbols/modes not called")
|
||||||
|
func firstZeroWins() {
|
||||||
Self.recorded = []
|
Self.recorded = []
|
||||||
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||||
"AP_ApplicationColorMatching")
|
"AP_ApplicationColorMatching")
|
||||||
Self.missing = []
|
Self.missing = []
|
||||||
let s = makeSuppressor()
|
let s = makeSuppressor()
|
||||||
XCTAssertTrue(s.applySPIMode(to: fakeSession))
|
#expect(s.applySPIMode(to: fakeSession))
|
||||||
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
|
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
|
||||||
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
|
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testModeFallback() {
|
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
|
||||||
|
func modeFallback() {
|
||||||
Self.recorded = []
|
Self.recorded = []
|
||||||
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||||
"ApplicationColorMatching")
|
"ApplicationColorMatching")
|
||||||
Self.missing = []
|
Self.missing = []
|
||||||
let s = makeSuppressor()
|
let s = makeSuppressor()
|
||||||
XCTAssertTrue(s.applySPIMode(to: fakeSession))
|
#expect(s.applySPIMode(to: fakeSession))
|
||||||
XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
|
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
|
||||||
XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
|
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
|
||||||
XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
|
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
|
||||||
XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
|
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
|
||||||
XCTAssertEqual(Self.recorded.count, 2)
|
#expect(Self.recorded.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAllMissing() {
|
@Test("All symbols missing → false, no calls")
|
||||||
|
func allMissing() {
|
||||||
Self.recorded = []
|
Self.recorded = []
|
||||||
Self.succeeding = nil
|
Self.succeeding = nil
|
||||||
Self.missing = Set(ColorMatchingAttempts.symbols)
|
Self.missing = Set(ColorMatchingAttempts.symbols)
|
||||||
let s = makeSuppressor()
|
let s = makeSuppressor()
|
||||||
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
|
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||||
XCTAssertTrue(Self.recorded.isEmpty)
|
#expect(Self.recorded.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||||
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||||
final class CupsParsersTests: XCTestCase {
|
@Suite("CupsParsers")
|
||||||
|
struct CupsParsersTests {
|
||||||
|
|
||||||
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||||
private let lpstatE = """
|
private let lpstatE = """
|
||||||
@@ -33,102 +34,112 @@ final class CupsParsersTests: XCTestCase {
|
|||||||
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
||||||
"""
|
"""
|
||||||
|
|
||||||
func testDestinations() {
|
@Test("lpstat -e: one destination per line; empty = success")
|
||||||
XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
|
func destinations() {
|
||||||
|
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
|
||||||
"Canon_Pro9500_II_series_XPS",
|
"Canon_Pro9500_II_series_XPS",
|
||||||
"Epson_XP_55_LPD",
|
"Epson_XP_55_LPD",
|
||||||
"EPSON_XP_55_Series",
|
"EPSON_XP_55_Series",
|
||||||
])
|
])
|
||||||
XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
|
#expect(CupsParsers.lpstatDestinations("") == [])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testStatuses() {
|
@Test("lpstat -p: idle / now-printing / disabled statuses")
|
||||||
|
func statuses() {
|
||||||
let s = CupsParsers.lpstatStatuses(lpstatP)
|
let s = CupsParsers.lpstatStatuses(lpstatP)
|
||||||
XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
|
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
|
||||||
XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
|
#expect(s["Epson_XP_55_LPD"] == .printing)
|
||||||
XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
|
#expect(s["EPSON_XP_55_Series"] == .stopped)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDefaultDestination() {
|
@Test("lpstat -d: default destination or none")
|
||||||
XCTAssertEqual(CupsParsers.lpstatDefault(
|
func defaultDestination() {
|
||||||
"system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
|
#expect(CupsParsers.lpstatDefault(
|
||||||
XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
|
"system default destination: Canon_Pro9500_II_series_XPS\n")
|
||||||
|
== "Canon_Pro9500_II_series_XPS")
|
||||||
|
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDisplayName() {
|
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
|
||||||
XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
|
func displayName() {
|
||||||
XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
|
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
|
||||||
|
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOptionListings() {
|
@Test("lpoptions -l: key/label split, * marks the default")
|
||||||
|
func optionListings() {
|
||||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||||
XCTAssertEqual(listings.count, 6)
|
#expect(listings.count == 6)
|
||||||
|
|
||||||
let page = listings[0]
|
let page = listings[0]
|
||||||
XCTAssertEqual(page.key, "PageSize")
|
#expect(page.key == "PageSize")
|
||||||
XCTAssertEqual(page.label, "Media Size")
|
#expect(page.label == "Media Size")
|
||||||
XCTAssertEqual(page.defaultChoice, "A4")
|
#expect(page.defaultChoice == "A4")
|
||||||
XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
|
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
|
||||||
XCTAssertFalse(page.choices.contains("*A4"))
|
#expect(!page.choices.contains("*A4"))
|
||||||
|
|
||||||
let slot = listings[1]
|
let slot = listings[1]
|
||||||
XCTAssertEqual(slot.key, "InputSlot")
|
#expect(slot.key == "InputSlot")
|
||||||
XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
|
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
|
||||||
XCTAssertEqual(slot.defaultChoice, "Main")
|
#expect(slot.defaultChoice == "Main")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCapabilities() {
|
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
|
||||||
|
func capabilities() {
|
||||||
let service = CupsService()
|
let service = CupsService()
|
||||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||||
let caps = service.capabilities(from: listings, ppd: nil)
|
let caps = service.capabilities(from: listings, ppd: nil)
|
||||||
|
|
||||||
XCTAssertEqual(caps.trays, [
|
#expect(caps.trays == [
|
||||||
PrinterTray(id: 1, name: "Auto"),
|
PrinterTray(id: 1, name: "Auto"),
|
||||||
PrinterTray(id: 2, name: "Main"),
|
PrinterTray(id: 2, name: "Main"),
|
||||||
PrinterTray(id: 3, name: "Photo"),
|
PrinterTray(id: 3, name: "Photo"),
|
||||||
PrinterTray(id: 4, name: "Rear"),
|
PrinterTray(id: 4, name: "Rear"),
|
||||||
])
|
])
|
||||||
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
|
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
|
||||||
XCTAssertEqual(caps.paperSizes.count, 10)
|
#expect(caps.paperSizes.count == 10)
|
||||||
XCTAssertEqual(caps.mediaTypes.map(\.id), [
|
#expect(caps.mediaTypes.map(\.id) == [
|
||||||
"Stationery", "PhotographicHighGloss", "Photographic",
|
"Stationery", "PhotographicHighGloss", "Photographic",
|
||||||
"PhotographicMatte", "Envelope",
|
"PhotographicMatte", "Envelope",
|
||||||
])
|
])
|
||||||
XCTAssertTrue(caps.supportsOrientation)
|
#expect(caps.supportsOrientation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPpdLabels() {
|
@Test("PPD enrichment maps id → human label")
|
||||||
|
func ppdLabels() {
|
||||||
let ppd = """
|
let ppd = """
|
||||||
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
|
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
|
||||||
*CNIJMediaType 0/Plain Paper: ""
|
*CNIJMediaType 0/Plain Paper: ""
|
||||||
*en_US.CNIJMediaType 13/Envelope: ""
|
*en_US.CNIJMediaType 13/Envelope: ""
|
||||||
"""
|
"""
|
||||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
|
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
|
||||||
XCTAssertEqual(labels["0"], "Plain Paper")
|
#expect(labels["0"] == "Plain Paper")
|
||||||
XCTAssertEqual(labels["13"], "Envelope")
|
#expect(labels["13"] == "Envelope")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMediaTypeKey() {
|
@Test("detectMediaTypeKey prefers vendor keys in order")
|
||||||
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
|
func mediaTypeKey() {
|
||||||
optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
|
#expect(CupsParsers.detectMediaTypeKey(
|
||||||
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
|
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
|
||||||
optionKeys: ["PageSize", "MediaType"]), "MediaType")
|
#expect(CupsParsers.detectMediaTypeKey(
|
||||||
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
|
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
|
||||||
|
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDriverBypass() {
|
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
|
||||||
|
func driverBypass() {
|
||||||
func pair(_ keys: Set<String>) -> String? {
|
func pair(_ keys: Set<String>) -> String? {
|
||||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||||
.map { "\($0.key)=\($0.value)" }
|
.map { "\($0.key)=\($0.value)" }
|
||||||
}
|
}
|
||||||
XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
|
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
|
||||||
XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
|
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
|
||||||
XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
|
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
|
||||||
XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
|
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
|
||||||
XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
|
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
|
||||||
XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
|
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
|
||||||
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
|
||||||
XCTAssertNil(pair(["PageSize"]))
|
#expect(pair(["PageSize"]) == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,65 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class DriftAlertTests: XCTestCase {
|
@Suite("DriftAlert")
|
||||||
|
struct DriftAlertTests {
|
||||||
|
|
||||||
func testNotEnough() {
|
@Test("No alert with fewer than two poor results")
|
||||||
|
func notEnough() {
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 1000)
|
record(avg: 4.0, at: 1000)
|
||||||
]
|
]
|
||||||
XCTAssertNil(DriftAlert.compute(from: records))
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOneHourApart() {
|
@Test("Alert on two poor results one hour apart")
|
||||||
|
func oneHourApart() {
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 1000),
|
record(avg: 4.0, at: 1000),
|
||||||
record(avg: 5.0, at: 4600)
|
record(avg: 5.0, at: 4600)
|
||||||
]
|
]
|
||||||
XCTAssertNotNil(DriftAlert.compute(from: records))
|
#expect(DriftAlert.compute(from: records) != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSameDayUnderHour() {
|
@Test("No alert if same day and under one hour")
|
||||||
|
func sameDayUnderHour() {
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 1000),
|
record(avg: 4.0, at: 1000),
|
||||||
record(avg: 5.0, at: 2000)
|
record(avg: 5.0, at: 2000)
|
||||||
]
|
]
|
||||||
XCTAssertNil(DriftAlert.compute(from: records))
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDistinctDays() {
|
@Test("Alert on distinct days")
|
||||||
|
func distinctDays() {
|
||||||
let day1 = record(avg: 4.0, at: 0)
|
let day1 = record(avg: 4.0, at: 0)
|
||||||
let day2 = record(avg: 5.0, at: 86400 + 1000)
|
let day2 = record(avg: 5.0, at: 86400 + 1000)
|
||||||
XCTAssertNotNil(DriftAlert.compute(from: [day1, day2]))
|
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNonPoor() {
|
@Test("Non-poor records do not trigger")
|
||||||
|
func nonPoor() {
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 1.0, at: 0),
|
record(avg: 1.0, at: 0),
|
||||||
record(avg: 1.5, at: 86400)
|
record(avg: 1.5, at: 86400)
|
||||||
]
|
]
|
||||||
XCTAssertNil(DriftAlert.compute(from: records))
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNonPoorBreaksRun() {
|
@Test("Non-poor records break the consecutive poor run")
|
||||||
|
func nonPoorBreaksRun() {
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 0), // poor
|
record(avg: 4.0, at: 0), // poor
|
||||||
record(avg: 4.5, at: 86400), // poor, far apart
|
record(avg: 4.5, at: 86400), // poor, far apart
|
||||||
record(avg: 1.0, at: 90000), // good — breaks the run
|
record(avg: 1.0, at: 90000), // good — breaks the run
|
||||||
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
|
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
|
||||||
]
|
]
|
||||||
XCTAssertNil(DriftAlert.compute(from: records))
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOnlySuffixRun() {
|
@Test("Only the final consecutive poor run is considered")
|
||||||
|
func onlySuffixRun() {
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 0), // poor
|
record(avg: 4.0, at: 0), // poor
|
||||||
record(avg: 4.5, at: 18000), // poor, > 1h from first
|
record(avg: 4.5, at: 18000), // poor, > 1h from first
|
||||||
@@ -59,24 +67,26 @@ final class DriftAlertTests: XCTestCase {
|
|||||||
record(avg: 4.0, at: 25000), // poor
|
record(avg: 4.0, at: 25000), // poor
|
||||||
record(avg: 4.5, at: 26000) // poor, < 1h and same day
|
record(avg: 4.5, at: 26000) // poor, < 1h and same day
|
||||||
]
|
]
|
||||||
XCTAssertNil(DriftAlert.compute(from: records))
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSuffixRunAlerts() {
|
@Test("Final consecutive poor run alerts when far apart")
|
||||||
|
func suffixRunAlerts() {
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 1.0, at: 0), // good
|
record(avg: 1.0, at: 0), // good
|
||||||
record(avg: 4.0, at: 1000), // poor
|
record(avg: 4.0, at: 1000), // poor
|
||||||
record(avg: 4.5, at: 4600) // poor, 1h after previous
|
record(avg: 4.5, at: 4600) // poor, 1h after previous
|
||||||
]
|
]
|
||||||
XCTAssertNotNil(DriftAlert.compute(from: records))
|
#expect(DriftAlert.compute(from: records) != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSingleFinalPoor() {
|
@Test("A single final poor record after good records does not alert")
|
||||||
|
func singleFinalPoor() {
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 1.0, at: 0),
|
record(avg: 1.0, at: 0),
|
||||||
record(avg: 4.0, at: 86400)
|
record(avg: 4.0, at: 86400)
|
||||||
]
|
]
|
||||||
XCTAssertNil(DriftAlert.compute(from: records))
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
|
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@@ -13,88 +13,91 @@ private func touch(_ url: URL, _ contents: String = "x") throws {
|
|||||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PathSecurityTests: XCTestCase {
|
@Suite("PathSecurity")
|
||||||
func testRejectsTraversalAndSeparators() {
|
struct PathSecurityTests {
|
||||||
|
@Test func rejectsTraversalAndSeparators() {
|
||||||
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
||||||
XCTAssertFalse(PathSecurity.isValidBasename(bad))
|
#expect(!PathSecurity.isValidBasename(bad))
|
||||||
XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
|
#expect(throws: PathSecurity.Error.self) {
|
||||||
XCTAssertTrue(error is PathSecurity.Error)
|
try PathSecurity.sanitizeBasename(bad)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAcceptsNormalNames() {
|
@Test func acceptsNormalNames() {
|
||||||
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
||||||
XCTAssertTrue(PathSecurity.isValidBasename(good))
|
#expect(PathSecurity.isValidBasename(good))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResolveSafeCwdPrefersExplicit() throws {
|
@Test func resolveSafeCwdPrefersExplicit() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
|
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResolveSafeCwdNeverReturnsNil() {
|
@Test func resolveSafeCwdNeverReturnsNil() {
|
||||||
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
||||||
let resolved = PathSecurity.resolveSafeCwd(missing)
|
let resolved = PathSecurity.resolveSafeCwd(missing)
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
|
#expect(FileManager.default.fileExists(atPath: resolved.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AtomicFileWriterTests: XCTestCase {
|
@Suite("AtomicFileWriter")
|
||||||
func testWritesAndLeavesNoTmp() throws {
|
struct AtomicFileWriterTests {
|
||||||
|
@Test func writesAndLeavesNoTmp() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = dir.appendingPathComponent("state.json")
|
let url = dir.appendingPathComponent("state.json")
|
||||||
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
||||||
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
|
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
|
||||||
XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOverwritesExistingAtomically() throws {
|
@Test func overwritesExistingAtomically() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = dir.appendingPathComponent("f.txt")
|
let url = dir.appendingPathComponent("f.txt")
|
||||||
try AtomicFileWriter.write("one", to: url)
|
try AtomicFileWriter.write("one", to: url)
|
||||||
try AtomicFileWriter.write("two-longer", to: url)
|
try AtomicFileWriter.write("two-longer", to: url)
|
||||||
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer")
|
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCreatesParentDirs() throws {
|
@Test func createsParentDirs() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
||||||
try AtomicFileWriter.write("{}", to: url)
|
try AtomicFileWriter.write("{}", to: url)
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ArtefactProbeTests: XCTestCase {
|
@Suite("ArtefactProbe")
|
||||||
func testVerifyProgression() throws {
|
struct ArtefactProbeTests {
|
||||||
|
@Test func verifyProgression() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
XCTAssertEqual(v, StageArtefacts())
|
#expect(v == StageArtefacts())
|
||||||
|
|
||||||
try touch(dir.appendingPathComponent("t.ti1"))
|
try touch(dir.appendingPathComponent("t.ti1"))
|
||||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
||||||
|
|
||||||
try touch(dir.appendingPathComponent("t.ti2"))
|
try touch(dir.appendingPathComponent("t.ti2"))
|
||||||
try touch(dir.appendingPathComponent("t.ti3"))
|
try touch(dir.appendingPathComponent("t.ti3"))
|
||||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
||||||
|
|
||||||
try touch(dir.appendingPathComponent("t.icc"))
|
try touch(dir.appendingPathComponent("t.icc"))
|
||||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testIcmWinsOverIcc() throws {
|
@Test func icmWinsOverIcc() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
try touch(dir.appendingPathComponent("p.icc"))
|
try touch(dir.appendingPathComponent("p.icc"))
|
||||||
try touch(dir.appendingPathComponent("p.icm"))
|
try touch(dir.appendingPathComponent("p.icm"))
|
||||||
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
|
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
|
||||||
XCTAssertEqual(profile?.pathExtension, "icm")
|
#expect(profile?.pathExtension == "icm")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEnumeratesPassesPagesAndCAL() throws {
|
@Test func enumeratesPassesPagesAndCAL() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
for name in [
|
for name in [
|
||||||
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||||
@@ -112,15 +115,15 @@ final class ArtefactProbeTests: XCTestCase {
|
|||||||
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||||
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
||||||
] {
|
] {
|
||||||
XCTAssertTrue(names.contains(expected), "missing \(expected)")
|
#expect(names.contains(expected), "missing \(expected)")
|
||||||
}
|
}
|
||||||
XCTAssertFalse(names.contains("other.ti1"))
|
#expect(!names.contains("other.ti1"))
|
||||||
XCTAssertFalse(names.contains("t.txt"))
|
#expect(!names.contains("t.txt"))
|
||||||
XCTAssertFalse(names.contains("CAL_other.ti1"))
|
#expect(!names.contains("CAL_other.ti1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEmptyDirReturnsEmpty() throws {
|
@Test func emptyDirReturnsEmpty() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
import SceneKit
|
import SceneKit
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// ``GamutSceneGeometryBuilder`` edge-case tests.
|
/// ``GamutSceneGeometryBuilder`` edge-case tests.
|
||||||
|
@Suite("Gamut scene geometry builder")
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GamutGeometryBuilderTests: XCTestCase {
|
struct GamutGeometryBuilderTests {
|
||||||
|
|
||||||
func testDropsOutOfBoundsFaces() {
|
@Test("Drops out-of-bounds faces from the element without crashing")
|
||||||
|
func dropsOutOfBoundsFaces() {
|
||||||
let white = GamutVertex(
|
let white = GamutVertex(
|
||||||
lab: LabColor(l: 100, a: 0, b: 0),
|
lab: LabColor(l: 100, a: 0, b: 0),
|
||||||
rgb: DisplayRGB(r: 1, g: 1, b: 1)
|
rgb: DisplayRGB(r: 1, g: 1, b: 1)
|
||||||
@@ -26,6 +28,6 @@ final class GamutGeometryBuilderTests: XCTestCase {
|
|||||||
|
|
||||||
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
|
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
|
||||||
|
|
||||||
XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer")
|
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// ``GamutMeshParser`` acceptance + edge-case tests.
|
/// ``GamutMeshParser`` acceptance + edge-case tests.
|
||||||
final class GamutMeshParserTests: XCTestCase {
|
@Suite("Gamut mesh parser")
|
||||||
|
struct GamutMeshParserTests {
|
||||||
|
|
||||||
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
|
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
|
||||||
private var bundledSRGBGamURL: URL {
|
private var bundledSRGBGamURL: URL {
|
||||||
@@ -12,14 +13,16 @@ final class GamutMeshParserTests: XCTestCase {
|
|||||||
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
|
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testParsesBundledSRGB() throws {
|
@Test("Parses bundled sRGB.gam")
|
||||||
|
func parsesBundledSRGB() throws {
|
||||||
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
|
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
|
||||||
|
|
||||||
XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
|
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
|
||||||
XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
|
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDiscardsVertexNo() throws {
|
@Test("Discards VERTEX_NO and uses push-order indices")
|
||||||
|
func discardsVertexNo() throws {
|
||||||
let text = """
|
let text = """
|
||||||
GAMUT
|
GAMUT
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
@@ -46,13 +49,14 @@ final class GamutMeshParserTests: XCTestCase {
|
|||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
|
|
||||||
XCTAssertEqual(mesh.vertices.count, 4)
|
#expect(mesh.vertices.count == 4)
|
||||||
XCTAssertEqual(mesh.faces.count, 2)
|
#expect(mesh.faces.count == 2)
|
||||||
XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30))
|
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30))
|
||||||
XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60))
|
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testIgnoresComments() throws {
|
@Test("Ignores comments and blank lines")
|
||||||
|
func ignoresComments() throws {
|
||||||
let text = """
|
let text = """
|
||||||
# Header comment
|
# Header comment
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
@@ -77,11 +81,12 @@ final class GamutMeshParserTests: XCTestCase {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
XCTAssertEqual(mesh.vertices.count, 2)
|
#expect(mesh.vertices.count == 2)
|
||||||
XCTAssertEqual(mesh.faces.count, 1)
|
#expect(mesh.faces.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRemapsCoordinates() throws {
|
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
|
||||||
|
func remapsCoordinates() throws {
|
||||||
let text = """
|
let text = """
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
BEGIN_DATA_FORMAT
|
BEGIN_DATA_FORMAT
|
||||||
@@ -94,10 +99,11 @@ final class GamutMeshParserTests: XCTestCase {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80))
|
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testComputesVertexColor() throws {
|
@Test("Computes per-vertex sRGB colour")
|
||||||
|
func computesVertexColor() throws {
|
||||||
let text = """
|
let text = """
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
BEGIN_DATA_FORMAT
|
BEGIN_DATA_FORMAT
|
||||||
@@ -110,13 +116,14 @@ final class GamutMeshParserTests: XCTestCase {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
let white = try XCTUnwrap(mesh.vertices.first).rgb
|
let white = try #require(mesh.vertices.first).rgb
|
||||||
XCTAssertTrue(white.r > 0.95)
|
#expect(white.r > 0.95)
|
||||||
XCTAssertTrue(white.g > 0.95)
|
#expect(white.g > 0.95)
|
||||||
XCTAssertTrue(white.b > 0.95)
|
#expect(white.b > 0.95)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDropsOutOfBoundsFaces() throws {
|
@Test("Drops out-of-bounds face indices")
|
||||||
|
func dropsOutOfBoundsFaces() throws {
|
||||||
let text = """
|
let text = """
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
BEGIN_DATA_FORMAT
|
BEGIN_DATA_FORMAT
|
||||||
@@ -139,23 +146,21 @@ final class GamutMeshParserTests: XCTestCase {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
XCTAssertEqual(mesh.faces.count, 1)
|
#expect(mesh.faces.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testThrowsOnEmptyFile() {
|
@Test("Throws on empty file")
|
||||||
XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
|
func throwsOnEmptyFile() {
|
||||||
guard case GamutMeshParseError.noDataBlock = error else {
|
#expect(throws: GamutMeshParseError.noDataBlock) {
|
||||||
return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
|
_ = try GamutMeshParser.parse(text: "")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testThrowsWhenMissing() {
|
@Test("Throws when file is missing")
|
||||||
|
func throwsWhenMissing() {
|
||||||
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
|
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
|
||||||
XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
|
#expect(throws: GamutMeshParseError.missingFile) {
|
||||||
guard case GamutMeshParseError.missingFile = error else {
|
_ = try GamutMeshParser.parse(url: url)
|
||||||
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class IccgamutArgsTests: XCTestCase {
|
@Suite("IccgamutArgs")
|
||||||
|
struct IccgamutArgsTests {
|
||||||
|
|
||||||
func testDensityNotDirectory() throws {
|
@Test("Density is 10 and not a directory")
|
||||||
|
func densityNotDirectory() throws {
|
||||||
let config = IccgamutConfig(
|
let config = IccgamutConfig(
|
||||||
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
|
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
|
||||||
)
|
)
|
||||||
let args = try IccgamutArgs.build(config: config)
|
let args = try IccgamutArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
|
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
|
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
|
||||||
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
|
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
|
||||||
/// flag order, captured-option precedence, and sanitisation.
|
/// flag order, captured-option precedence, and sanitisation.
|
||||||
final class LpArgsTests: XCTestCase {
|
@Suite("LpArgs")
|
||||||
|
struct LpArgsTests {
|
||||||
|
|
||||||
private let tiff = "/tmp/work/target_001.tif"
|
private let tiff = "/tmp/work/target_001.tif"
|
||||||
private let queue = "EPSON_XP_55_Series"
|
private let queue = "EPSON_XP_55_Series"
|
||||||
@@ -19,100 +20,112 @@ final class LpArgsTests: XCTestCase {
|
|||||||
options: options, optionKeys: optionKeys)
|
options: options, optionKeys: optionKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHeader() throws {
|
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
|
||||||
|
func header() throws {
|
||||||
let argv = try build()
|
let argv = try build()
|
||||||
XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
|
#expect(Array(argv[0...1]) == ["-d", queue])
|
||||||
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
|
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
|
||||||
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
|
#expect(Array(argv[4...5])
|
||||||
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
|
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||||
XCTAssertEqual(argv.last, tiff)
|
#expect(Array(argv[6...7])
|
||||||
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
|
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||||
|
#expect(argv.last == tiff)
|
||||||
|
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNeverRaw() throws {
|
@Test("Never emits -o raw; captured raw= is dropped")
|
||||||
|
func neverRaw() throws {
|
||||||
let argv = try build(options: PrintOptions(
|
let argv = try build(options: PrintOptions(
|
||||||
cupsOptions: "raw=true MediaType=Photo"))
|
cupsOptions: "raw=true MediaType=Photo"))
|
||||||
for (i, arg) in argv.enumerated() where arg == "-o" {
|
for (i, arg) in argv.enumerated() where arg == "-o" {
|
||||||
XCTAssertNotEqual(argv[i + 1], "raw")
|
#expect(argv[i + 1] != "raw")
|
||||||
XCTAssertNotEqual(argv[i + 1], "raw=true")
|
#expect(argv[i + 1] != "raw=true")
|
||||||
}
|
}
|
||||||
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
|
#expect(!argv.contains { $0.hasPrefix("raw=") })
|
||||||
XCTAssertTrue(argv.contains("MediaType=Photo"))
|
#expect(argv.contains("MediaType=Photo"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCapturedReplay() throws {
|
@Test("Captured options replayed after AP_* headers")
|
||||||
|
func capturedReplay() throws {
|
||||||
let argv = try build(options: PrintOptions(
|
let argv = try build(options: PrintOptions(
|
||||||
cupsOptions: "InputSlot=Rear MediaType=Photo"))
|
cupsOptions: "InputSlot=Rear MediaType=Photo"))
|
||||||
let rear = argv.firstIndex(of: "InputSlot=Rear")!
|
let rear = argv.firstIndex(of: "InputSlot=Rear")!
|
||||||
let apFirst = argv.firstIndex(of:
|
let apFirst = argv.firstIndex(of:
|
||||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
|
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
|
||||||
XCTAssertTrue(rear > apFirst)
|
#expect(rear > apFirst)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCapturedWinsMedia() throws {
|
@Test("Captured wins: media key present → derived media skipped")
|
||||||
|
func capturedWinsMedia() throws {
|
||||||
let argv = try build(
|
let argv = try build(
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
mediaType: "Plain",
|
mediaType: "Plain",
|
||||||
cupsOptions: "MediaType=Glossy"),
|
cupsOptions: "MediaType=Glossy"),
|
||||||
optionKeys: ["MediaType"])
|
optionKeys: ["MediaType"])
|
||||||
XCTAssertTrue(argv.contains("MediaType=Glossy"))
|
#expect(argv.contains("MediaType=Glossy"))
|
||||||
XCTAssertFalse(argv.contains("MediaType=Plain"))
|
#expect(!argv.contains("MediaType=Plain"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMediaDerived() throws {
|
@Test("Media emitted via detected key when not captured")
|
||||||
|
func mediaDerived() throws {
|
||||||
let argv = try build(
|
let argv = try build(
|
||||||
options: PrintOptions(mediaType: "SemiGloss"),
|
options: PrintOptions(mediaType: "SemiGloss"),
|
||||||
optionKeys: ["CNIJMediaType", "MediaType"])
|
optionKeys: ["CNIJMediaType", "MediaType"])
|
||||||
// CNIJMediaType wins over MediaType in detection order.
|
// CNIJMediaType wins over MediaType in detection order.
|
||||||
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
|
#expect(argv.contains("CNIJMediaType=SemiGloss"))
|
||||||
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
|
#expect(!argv.contains("MediaType=SemiGloss"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBypassRules() throws {
|
@Test("Driver bypass emitted when absent, skipped when captured")
|
||||||
|
func bypassRules() throws {
|
||||||
let withBypass = try build(
|
let withBypass = try build(
|
||||||
optionKeys: ["EPIJ_CMat"])
|
optionKeys: ["EPIJ_CMat"])
|
||||||
XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
|
#expect(withBypass.contains("EPIJ_CMat=3"))
|
||||||
|
|
||||||
let captured = try build(
|
let captured = try build(
|
||||||
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
|
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
|
||||||
optionKeys: ["EPIJ_CMat"])
|
optionKeys: ["EPIJ_CMat"])
|
||||||
// Captured value kept, detection not re-applied.
|
// Captured value kept, detection not re-applied.
|
||||||
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
|
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
|
||||||
|
== ["EPIJ_CMat=1"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOrientation() throws {
|
@Test("Orientation: portrait=3 landscape=4; captured wins")
|
||||||
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
|
func orientation() throws {
|
||||||
|
#expect(try build(options: PrintOptions(orientation: "portrait"))
|
||||||
.contains("orientation-requested=3"))
|
.contains("orientation-requested=3"))
|
||||||
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
|
#expect(try build(options: PrintOptions(orientation: "landscape"))
|
||||||
.contains("orientation-requested=4"))
|
.contains("orientation-requested=4"))
|
||||||
let capturedOrients = try build(options: PrintOptions(
|
let capturedOrients = try build(options: PrintOptions(
|
||||||
orientation: "landscape",
|
orientation: "landscape",
|
||||||
cupsOptions: "orientation-requested=5"))
|
cupsOptions: "orientation-requested=5"))
|
||||||
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
|
#expect(!capturedOrients.contains("orientation-requested=4"))
|
||||||
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
|
#expect(capturedOrients.contains("orientation-requested=5"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPageSize() throws {
|
@Test("PageSize emitted unless captured")
|
||||||
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
|
func pageSize() throws {
|
||||||
|
#expect(try build(options: PrintOptions(paperSize: "A4"))
|
||||||
.contains("PageSize=A4"))
|
.contains("PageSize=A4"))
|
||||||
let capturedSize = try build(options: PrintOptions(
|
let capturedSize = try build(options: PrintOptions(
|
||||||
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
||||||
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
|
#expect(!capturedSize.contains("PageSize=A4"))
|
||||||
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
|
#expect(capturedSize.contains("PageSize=Letter"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSanitise() throws {
|
@Test("Sanitise rejects `;`, newline, and shell metachars")
|
||||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
func sanitise() throws {
|
||||||
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
|
#expect(throws: LpArgsError.self) {
|
||||||
XCTAssertTrue(error is LpArgsError)
|
_ = try build(options: PrintOptions(
|
||||||
|
cupsOptions: "InputSlot=Rear;rm -rf /"))
|
||||||
}
|
}
|
||||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
#expect(throws: LpArgsError.self) {
|
||||||
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
|
_ = try build(options: PrintOptions(
|
||||||
XCTAssertTrue(error is LpArgsError)
|
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
|
||||||
}
|
}
|
||||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
#expect(throws: LpArgsError.self) {
|
||||||
cupsOptions: "InputSlot=$(whoami)"))) { error in
|
_ = try build(options: PrintOptions(
|
||||||
XCTAssertTrue(error is LpArgsError)
|
cupsOptions: "InputSlot=$(whoami)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class InstrumentParserTests: XCTestCase {
|
@Suite("InstrumentParser")
|
||||||
|
struct InstrumentParserTests {
|
||||||
|
|
||||||
func testJson() throws {
|
@Test("Parses pretty-printed instlist JSON")
|
||||||
|
func json() throws {
|
||||||
let json = """
|
let json = """
|
||||||
{
|
{
|
||||||
"event": "instruments",
|
"event": "instruments",
|
||||||
@@ -16,118 +18,134 @@ final class InstrumentParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
let devices = try InstrumentParser.parse(json)
|
let devices = try InstrumentParser.parse(json)
|
||||||
XCTAssertEqual(devices.count, 3)
|
#expect(devices.count == 3)
|
||||||
XCTAssertEqual(devices[0].port, 1)
|
#expect(devices[0].port == 1)
|
||||||
XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
|
#expect(devices[0].name == "X-Rite i1Pro")
|
||||||
XCTAssertEqual(devices[2].port, 3)
|
#expect(devices[2].port == 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRegexFallback() throws {
|
@Test("Falls back to regex for legacy instlist text")
|
||||||
|
func regexFallback() throws {
|
||||||
let text = """
|
let text = """
|
||||||
1: 'X-Rite i1Pro' on usb
|
1: 'X-Rite i1Pro' on usb
|
||||||
2: 'ColorMunki Smile'
|
2: 'ColorMunki Smile'
|
||||||
""" + "\n"
|
""" + "\n"
|
||||||
let devices = try InstrumentParser.parse(text)
|
let devices = try InstrumentParser.parse(text)
|
||||||
XCTAssertEqual(devices.count, 2)
|
#expect(devices.count == 2)
|
||||||
XCTAssertEqual(devices[0].port, 1)
|
#expect(devices[0].port == 1)
|
||||||
XCTAssertEqual(devices[1].name, "ColorMunki Smile")
|
#expect(devices[1].name == "ColorMunki Smile")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEmpty() throws {
|
@Test("Empty output returns no devices")
|
||||||
XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
|
func empty() throws {
|
||||||
|
#expect(try InstrumentParser.parse("").isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ChartreadArgsTests: XCTestCase {
|
@Suite("ChartreadArgs")
|
||||||
|
struct ChartreadArgsTests {
|
||||||
|
|
||||||
func testBaseline() throws {
|
@Test("Baseline argv and port 1 omits -c")
|
||||||
|
func baseline() throws {
|
||||||
let config = ChartreadConfig(basename: "target", selectedPort: 1)
|
let config = ChartreadConfig(basename: "target", selectedPort: 1)
|
||||||
let args = try ChartreadArgs.build(config: config)
|
let args = try ChartreadArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-u", "target"])
|
#expect(args == ["-v", "-u", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPortArgument() throws {
|
@Test("Port > 1 emits -c")
|
||||||
|
func portArgument() throws {
|
||||||
let config = ChartreadConfig(basename: "target", selectedPort: 3)
|
let config = ChartreadConfig(basename: "target", selectedPort: 3)
|
||||||
let args = try ChartreadArgs.build(config: config)
|
let args = try ChartreadArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"])
|
#expect(args == ["-v", "-u", "-c", "3", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLeds() throws {
|
@Test("LEDs emit -Y l")
|
||||||
|
func leds() throws {
|
||||||
let config = ChartreadConfig(
|
let config = ChartreadConfig(
|
||||||
basename: "target",
|
basename: "target",
|
||||||
selectedPort: 2,
|
selectedPort: 2,
|
||||||
enableLEDs: true
|
enableLEDs: true
|
||||||
)
|
)
|
||||||
let args = try ChartreadArgs.build(config: config)
|
let args = try ChartreadArgs.build(config: config)
|
||||||
XCTAssertTrue(args.contains("-Y"))
|
#expect(args.contains("-Y"))
|
||||||
XCTAssertTrue(args.contains("l"))
|
#expect(args.contains("l"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAutoPort() throws {
|
@Test("Auto omits -c")
|
||||||
|
func autoPort() throws {
|
||||||
let config = ChartreadConfig(basename: "target")
|
let config = ChartreadConfig(basename: "target")
|
||||||
let args = try ChartreadArgs.build(config: config)
|
let args = try ChartreadArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("-c"))
|
#expect(!args.contains("-c"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ChartreadClassifierTests: XCTestCase {
|
@Suite("ChartreadClassifier")
|
||||||
|
struct ChartreadClassifierTests {
|
||||||
|
|
||||||
func testCalibration() {
|
@Test("Calibration prompt")
|
||||||
|
func calibration() {
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
|
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
|
||||||
previousState: .idle
|
previousState: .idle
|
||||||
)
|
)
|
||||||
XCTAssertEqual(r.state, .calibrating)
|
#expect(r.state == .calibrating)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAwaitingStrip() {
|
@Test("Strip awaiting")
|
||||||
|
func awaitingStrip() {
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "Hit [Space] to read strip A",
|
line: "Hit [Space] to read strip A",
|
||||||
previousState: .calibrating
|
previousState: .calibrating
|
||||||
)
|
)
|
||||||
XCTAssertEqual(r.state, .awaitingStrip)
|
#expect(r.state == .awaitingStrip)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDone() {
|
@Test("Done prompt")
|
||||||
|
func done() {
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "'d' if/when done",
|
line: "'d' if/when done",
|
||||||
previousState: .awaitingStrip
|
previousState: .awaitingStrip
|
||||||
)
|
)
|
||||||
XCTAssertEqual(r.state, .allStripsRead)
|
#expect(r.state == .allStripsRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPlaceSheet() {
|
@Test("XY place sheet")
|
||||||
|
func placeSheet() {
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "Please place sheet 1 of 2 on the table",
|
line: "Please place sheet 1 of 2 on the table",
|
||||||
previousState: .idle
|
previousState: .idle
|
||||||
)
|
)
|
||||||
XCTAssertEqual(r.state, .tablePlaceSheet)
|
#expect(r.state == .tablePlaceSheet)
|
||||||
XCTAssertEqual(r.sheetNumber, 1)
|
#expect(r.sheetNumber == 1)
|
||||||
XCTAssertEqual(r.sheetTotal, 2)
|
#expect(r.sheetTotal == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLocatePatch() {
|
@Test("XY locate patch")
|
||||||
|
func locatePatch() {
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "locate patch A1 with the sight,",
|
line: "locate patch A1 with the sight,",
|
||||||
previousState: .tablePlaceSheet
|
previousState: .tablePlaceSheet
|
||||||
)
|
)
|
||||||
XCTAssertEqual(r.state, .tableAlign)
|
#expect(r.state == .tableAlign)
|
||||||
XCTAssertEqual(r.alignmentPatch, "A1")
|
#expect(r.alignmentPatch == "A1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRemoveNotice() {
|
@Test("Remove sheet notice preserves state")
|
||||||
|
func removeNotice() {
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "Please remove last sheet from table",
|
line: "Please remove last sheet from table",
|
||||||
previousState: .tablePlaceSheet
|
previousState: .tablePlaceSheet
|
||||||
)
|
)
|
||||||
XCTAssertEqual(r.state, .tablePlaceSheet)
|
#expect(r.state == .tablePlaceSheet)
|
||||||
XCTAssertEqual(r.isRemoveSheetNotice, true)
|
#expect(r.isRemoveSheetNotice == true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ChartreadRowTests: XCTestCase {
|
@Suite("ChartreadRow")
|
||||||
|
struct ChartreadRowTests {
|
||||||
|
|
||||||
func testDecode() throws {
|
@Test("Decodes row JSON")
|
||||||
|
func decode() throws {
|
||||||
let json = """
|
let json = """
|
||||||
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
|
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
|
||||||
"patch_count": 1, "patches": [
|
"patch_count": 1, "patches": [
|
||||||
@@ -137,93 +155,59 @@ final class ChartreadRowTests: XCTestCase {
|
|||||||
]}
|
]}
|
||||||
"""
|
"""
|
||||||
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
||||||
XCTAssertEqual(row.rowId, "A")
|
#expect(row.rowId == "A")
|
||||||
XCTAssertEqual(row.patchCount, 1)
|
#expect(row.patchCount == 1)
|
||||||
XCTAssertEqual(row.patches[0].measured.lab?.l, 51)
|
#expect(row.patches[0].measured.lab?.l == 51)
|
||||||
}
|
|
||||||
|
|
||||||
func testDecodeXYZAndLab() throws {
|
|
||||||
let json = """
|
|
||||||
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
|
|
||||||
"patch_count": 1, "patches": [
|
|
||||||
{"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40],
|
|
||||||
"measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}}
|
|
||||||
]}
|
|
||||||
"""
|
|
||||||
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
|
||||||
let measured = row.patches[0].measured
|
|
||||||
XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
|
|
||||||
XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testXyzWireEncoding() throws {
|
|
||||||
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
|
|
||||||
let value = try JSONSerialization.jsonObject(
|
|
||||||
with: JSONEncoder().encode(color))
|
|
||||||
XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLabWireEncoding() throws {
|
|
||||||
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
|
|
||||||
let value = try JSONSerialization.jsonObject(
|
|
||||||
with: JSONEncoder().encode(color))
|
|
||||||
XCTAssertEqual(value as? [Double], [50, -1, 2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPatchColorKeys() throws {
|
|
||||||
let color = PatchColor(
|
|
||||||
xyz: CIEXYZ(x: 10, y: 20, z: 30),
|
|
||||||
lab: CIELab(l: 55, a: 1, b: -2))
|
|
||||||
let object = try JSONSerialization.jsonObject(
|
|
||||||
with: JSONEncoder().encode(color)) as? [String: Any]
|
|
||||||
XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
|
|
||||||
XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
|
|
||||||
XCTAssertNil(object?["spectral"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ColourMathTests: XCTestCase {
|
@Suite("ColourMath")
|
||||||
|
struct ColourMathTests {
|
||||||
|
|
||||||
func testWhiteLab() {
|
@Test("White XYZ to Lab")
|
||||||
|
func whiteLab() {
|
||||||
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
|
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
|
||||||
let lab = LabColorMath.xyzToLab(white)
|
let lab = LabColorMath.xyzToLab(white)
|
||||||
XCTAssertTrue(abs(lab.l - 100) < 0.5)
|
#expect(abs(lab.l - 100) < 0.5)
|
||||||
XCTAssertTrue(abs(lab.a) < 0.5)
|
#expect(abs(lab.a) < 0.5)
|
||||||
XCTAssertTrue(abs(lab.b) < 0.5)
|
#expect(abs(lab.b) < 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLabToSRGB() {
|
@Test("Lab to sRGB roundtrip is clamped")
|
||||||
|
func labToSRGB() {
|
||||||
let red = LabColor(l: 55, a: 80, b: 70)
|
let red = LabColor(l: 55, a: 80, b: 70)
|
||||||
let rgb = LabColorMath.labToSRGB(red)
|
let rgb = LabColorMath.labToSRGB(red)
|
||||||
XCTAssertTrue(rgb.r > 0.8)
|
#expect(rgb.r > 0.8)
|
||||||
XCTAssertTrue(rgb.g < 0.2)
|
#expect(rgb.g < 0.2)
|
||||||
XCTAssertTrue(rgb.b < 0.2)
|
#expect(rgb.b < 0.2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPadWhite() {
|
@Test("Pad white returns DisplayRGB")
|
||||||
|
func padWhite() {
|
||||||
let white = LabColor(l: 95, a: 0, b: 0)
|
let white = LabColor(l: 95, a: 0, b: 0)
|
||||||
let rgb = LabColorMath.labToSRGB(white)
|
let rgb = LabColorMath.labToSRGB(white)
|
||||||
XCTAssertTrue(rgb.r > 0.9)
|
#expect(rgb.r > 0.9)
|
||||||
XCTAssertTrue(rgb.g > 0.9)
|
#expect(rgb.g > 0.9)
|
||||||
XCTAssertTrue(rgb.b > 0.9)
|
#expect(rgb.b > 0.9)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCiede2000() {
|
@Test("Standard CIEDE2000 vector (Sharma)")
|
||||||
|
func ciede2000() {
|
||||||
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
|
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
|
||||||
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
|
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
|
||||||
XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
|
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testClassify() {
|
@Test("Classification respects thresholds")
|
||||||
XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good)
|
func classify() {
|
||||||
XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning)
|
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
|
||||||
XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad)
|
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
|
||||||
|
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class MeasurementArtefactTests: XCTestCase {
|
@Suite("MeasurementArtefacts")
|
||||||
|
struct MeasurementArtefactTests {
|
||||||
|
|
||||||
private func makeCwd() throws -> URL {
|
private func makeCwd() throws -> URL {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
@@ -232,7 +216,8 @@ final class MeasurementArtefactTests: XCTestCase {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDiscovery() throws {
|
@Test("Discovers passes in order")
|
||||||
|
func discovery() throws {
|
||||||
let cwd = try makeCwd()
|
let cwd = try makeCwd()
|
||||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||||
|
|
||||||
@@ -241,10 +226,11 @@ final class MeasurementArtefactTests: XCTestCase {
|
|||||||
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
|
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
|
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
|
||||||
XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
|
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSnapshotPromote() throws {
|
@Test("Snapshot and promote are atomic")
|
||||||
|
func snapshotPromote() throws {
|
||||||
let cwd = try makeCwd()
|
let cwd = try makeCwd()
|
||||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||||
|
|
||||||
@@ -252,15 +238,16 @@ final class MeasurementArtefactTests: XCTestCase {
|
|||||||
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
|
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||||
XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
|
#expect(pass.lastPathComponent == "target_pass1.ti3")
|
||||||
XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
|
#expect(!FileManager.default.fileExists(atPath: canonical.path))
|
||||||
|
|
||||||
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
|
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
|
||||||
XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
|
#expect(promoted.lastPathComponent == "target.ti3")
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
|
#expect(FileManager.default.fileExists(atPath: promoted.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCollision() throws {
|
@Test("Pass collisions handled")
|
||||||
|
func collision() throws {
|
||||||
let cwd = try makeCwd()
|
let cwd = try makeCwd()
|
||||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||||
|
|
||||||
@@ -270,25 +257,28 @@ final class MeasurementArtefactTests: XCTestCase {
|
|||||||
|
|
||||||
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
|
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
|
||||||
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||||
XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
|
#expect(pass2.lastPathComponent == "target_pass2.ti3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AverageArgsTests: XCTestCase {
|
@Suite("AverageArgs")
|
||||||
|
struct AverageArgsTests {
|
||||||
|
|
||||||
func testPassCount() {
|
@Test("Requires at least two pass files")
|
||||||
|
func passCount() {
|
||||||
let cwd = URL(fileURLWithPath: "/tmp")
|
let cwd = URL(fileURLWithPath: "/tmp")
|
||||||
let config = AverageConfig(
|
let config = AverageConfig(
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
basename: "target",
|
basename: "target",
|
||||||
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
|
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
|
||||||
)
|
)
|
||||||
XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
|
#expect(throws: AverageArgError.self) {
|
||||||
XCTAssertTrue(error is AverageArgError)
|
_ = try AverageArgs.build(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOrdering() throws {
|
@Test("Output is last and inputs are relative")
|
||||||
|
func ordering() throws {
|
||||||
let cwd = URL(fileURLWithPath: "/tmp")
|
let cwd = URL(fileURLWithPath: "/tmp")
|
||||||
let config = AverageConfig(
|
let config = AverageConfig(
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
@@ -299,8 +289,8 @@ final class AverageArgsTests: XCTestCase {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
let args = try AverageArgs.build(config: config)
|
let args = try AverageArgs.build(config: config)
|
||||||
XCTAssertEqual(args.first, "-v")
|
#expect(args.first == "-v")
|
||||||
XCTAssertEqual(args.last, "target.ti3")
|
#expect(args.last == "target.ti3")
|
||||||
XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
|
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class PrintcalArgsTests: XCTestCase {
|
@Suite("PrintcalArgs")
|
||||||
|
struct PrintcalArgsTests {
|
||||||
|
|
||||||
private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
|
private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
|
||||||
|
|
||||||
func testDefaults() throws {
|
@Test("Default printcal argv")
|
||||||
|
func defaults() throws {
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: "CAL_demo",
|
ti3Basename: "CAL_demo",
|
||||||
outputURL: tmp
|
outputURL: tmp
|
||||||
)
|
)
|
||||||
let args = try PrintcalArgs.build(config: config)
|
let args = try PrintcalArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
|
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAllOptions() throws {
|
@Test("All options and channel limits")
|
||||||
|
func allOptions() throws {
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: "demo",
|
ti3Basename: "demo",
|
||||||
outputURL: tmp,
|
outputURL: tmp,
|
||||||
@@ -29,7 +32,7 @@ final class PrintcalArgsTests: XCTestCase {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
let args = try PrintcalArgs.build(config: config)
|
let args = try PrintcalArgs.build(config: config)
|
||||||
XCTAssertEqual(args, [
|
#expect(args == [
|
||||||
"-v", "-e",
|
"-v", "-e",
|
||||||
"-I", "-z",
|
"-I", "-z",
|
||||||
"-a", "/tmp/old.cal",
|
"-a", "/tmp/old.cal",
|
||||||
@@ -41,34 +44,16 @@ final class PrintcalArgsTests: XCTestCase {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWhitespacePreviousCal() throws {
|
@Test("Rejects invalid per-channel limit")
|
||||||
let config = PrintcalConfig(
|
func rejectsBadChannelLimit() {
|
||||||
ti3Basename: "demo",
|
|
||||||
outputURL: tmp,
|
|
||||||
previousCalPath: " \n\t "
|
|
||||||
)
|
|
||||||
let args = try PrintcalArgs.build(config: config)
|
|
||||||
XCTAssertFalse(args.contains("-a"))
|
|
||||||
XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPreviousCalTrimmed() throws {
|
|
||||||
let config = PrintcalConfig(
|
|
||||||
ti3Basename: "demo",
|
|
||||||
outputURL: tmp,
|
|
||||||
previousCalPath: " /tmp/old.cal "
|
|
||||||
)
|
|
||||||
let args = try PrintcalArgs.build(config: config)
|
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-a")! + 1], "/tmp/old.cal")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRejectsBadChannelLimit() {
|
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: "demo",
|
ti3Basename: "demo",
|
||||||
outputURL: tmp,
|
outputURL: tmp,
|
||||||
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
|
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
|
||||||
)
|
)
|
||||||
XCTAssertThrowsError(try PrintcalArgs.build(config: config))
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try PrintcalArgs.build(config: config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Testing
|
import Testing
|
||||||
import XCTest
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class PrinttargArgsTests: XCTestCase {
|
@Suite("PrinttargArgs")
|
||||||
|
struct PrinttargArgsTests {
|
||||||
|
|
||||||
private func config(
|
private func config(
|
||||||
instrument: PrintInstrument = .i1,
|
instrument: PrintInstrument = .i1,
|
||||||
@@ -28,121 +28,119 @@ final class PrinttargArgsTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBaseline() throws {
|
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300")
|
||||||
|
func baseline() throws {
|
||||||
let args = try PrinttargArgs.build(config: config())
|
let args = try PrinttargArgs.build(config: config())
|
||||||
XCTAssertEqual(args, ["-v", "-u", "-i", "i1", "-p", "A4",
|
#expect(args == ["-v", "-u", "-i", "i1", "-p", "A4",
|
||||||
"-R", "1", "-t", "300", "target"])
|
"-R", "1", "-t", "300", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDeterministicDefault() throws {
|
@Test("Default layout is deterministic -R 1, never bare")
|
||||||
|
func deterministicDefault() throws {
|
||||||
let args = try PrinttargArgs.build(config: config())
|
let args = try PrinttargArgs.build(config: config())
|
||||||
XCTAssertTrue(args.contains("-R"))
|
#expect(args.contains("-R"))
|
||||||
XCTAssertFalse(args.contains("-r"))
|
#expect(!args.contains("-r"))
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1")
|
#expect(args[args.firstIndex(of: "-R")! + 1] == "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCustomSeed() throws {
|
@Test("Custom seed -R N; seed < 1 throws")
|
||||||
|
func customSeed() throws {
|
||||||
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
|
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42")
|
#expect(args[args.firstIndex(of: "-R")! + 1] == "42")
|
||||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in
|
#expect(throws: PrinttargArgError.self) {
|
||||||
XCTAssertTrue(error is PrinttargArgError)
|
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRaster() throws {
|
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)")
|
||||||
|
func raster() throws {
|
||||||
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
|
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
|
||||||
XCTAssertTrue(args.contains("-r"))
|
#expect(args.contains("-r"))
|
||||||
XCTAssertFalse(args.contains("-R"))
|
#expect(!args.contains("-R"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLabel() throws {
|
@Test("Label: -d emits the resolved string, not a colour space")
|
||||||
|
func label() throws {
|
||||||
let args = try PrinttargArgs.build(
|
let args = try PrinttargArgs.build(
|
||||||
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
|
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
|
||||||
let i = args.firstIndex(of: "-d")!
|
let i = args.firstIndex(of: "-d")!
|
||||||
XCTAssertTrue(args[i + 1].hasPrefix("ICCery - t"))
|
#expect(args[i + 1].hasPrefix("ICCery - t"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBitDepthAndDPI() throws {
|
@Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600")
|
||||||
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
|
func bitDepthAndDPI() throws {
|
||||||
|
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
|
||||||
.contains("-T"))
|
.contains("-T"))
|
||||||
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
|
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
|
||||||
.contains("-t"))
|
.contains("-t"))
|
||||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in
|
#expect(throws: PrinttargArgError.self) {
|
||||||
XCTAssertTrue(error is PrinttargArgError)
|
try PrinttargArgs.build(config: config(dpi: 71))
|
||||||
}
|
}
|
||||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in
|
#expect(throws: PrinttargArgError.self) {
|
||||||
XCTAssertTrue(error is PrinttargArgError)
|
try PrinttargArgs.build(config: config(dpi: 601))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInstruments() throws {
|
@Test("All instruments emit their Argyll code")
|
||||||
|
func instruments() throws {
|
||||||
let expected: [(PrintInstrument, String)] = [
|
let expected: [(PrintInstrument, String)] = [
|
||||||
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
|
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
|
||||||
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
|
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
|
||||||
]
|
]
|
||||||
for (inst, code) in expected {
|
for (inst, code) in expected {
|
||||||
let args = try PrinttargArgs.build(config: config(instrument: inst))
|
let args = try PrinttargArgs.build(config: config(instrument: inst))
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-i")! + 1], code)
|
#expect(args[args.firstIndex(of: "-i")! + 1] == code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPageSizes() throws {
|
@Test("All fixed page sizes; custom emits WxH in mm")
|
||||||
|
func pageSizes() throws {
|
||||||
for size in PageSize.allCases where size != .custom {
|
for size in PageSize.allCases where size != .custom {
|
||||||
let args = try PrinttargArgs.build(config: config(pageSize: size))
|
let args = try PrinttargArgs.build(config: config(pageSize: size))
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-p")! + 1], size.rawValue)
|
#expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue)
|
||||||
}
|
}
|
||||||
let custom = try PrinttargArgs.build(config: config(
|
let custom = try PrinttargArgs.build(config: config(
|
||||||
pageSize: .custom, customW: 150, customH: 220))
|
pageSize: .custom, customW: 150, customH: 220))
|
||||||
XCTAssertEqual(custom[custom.firstIndex(of: "-p")! + 1], "150x220")
|
#expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCustomPageTooSmall() {
|
@Test("Custom page below 50 mm throws")
|
||||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in
|
func customPageTooSmall() {
|
||||||
XCTAssertTrue(error is PrinttargArgError)
|
#expect(throws: PrinttargArgError.self) {
|
||||||
|
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
|
||||||
}
|
}
|
||||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in
|
#expect(throws: PrinttargArgError.self) {
|
||||||
XCTAssertTrue(error is PrinttargArgError)
|
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCalibrationFlags() throws {
|
@Test("Calibration: -K applies, -I embeds")
|
||||||
|
func calibrationFlags() throws {
|
||||||
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal"))
|
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal"))
|
||||||
XCTAssertEqual(k[k.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
|
#expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
|
||||||
let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
|
let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
|
||||||
XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal")
|
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal")
|
||||||
XCTAssertFalse(i.contains("-K"))
|
#expect(!i.contains("-K"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCalProtection() throws {
|
@Test("CAL_ basename never gets -K or -I")
|
||||||
|
func calProtection() throws {
|
||||||
let args = try PrinttargArgs.build(
|
let args = try PrinttargArgs.build(
|
||||||
config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
|
config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
|
||||||
XCTAssertFalse(args.contains("-K"))
|
#expect(!args.contains("-K"))
|
||||||
XCTAssertFalse(args.contains("-I"))
|
#expect(!args.contains("-I"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWhitespaceOptions() throws {
|
@Test("Unsafe basename throws")
|
||||||
let args = try PrinttargArgs.build(
|
func unsafeBasename() {
|
||||||
config: config(label: " \n ", calFile: " \t "))
|
#expect(throws: PathSecurity.Error.self) {
|
||||||
XCTAssertFalse(args.contains("-d"))
|
try PrinttargArgs.build(config: config(basename: "../x"))
|
||||||
XCTAssertFalse(args.contains("-K"))
|
|
||||||
XCTAssertFalse(args.contains("-I"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTrimmedOptions() throws {
|
|
||||||
let args = try PrinttargArgs.build(
|
|
||||||
config: config(label: " My Label ", calFile: " /tmp/a.cal "))
|
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-d")! + 1], "My Label")
|
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testUnsafeBasename() {
|
|
||||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(basename: "../x"))) { error in
|
|
||||||
XCTAssertTrue(error is PathSecurity.Error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PrinttargLabelTests: XCTestCase {
|
@Suite("PrinttargLabel")
|
||||||
|
struct PrinttargLabelTests {
|
||||||
|
|
||||||
private var fixedDate: Date {
|
private var fixedDate: Date {
|
||||||
var comps = DateComponents()
|
var comps = DateComponents()
|
||||||
@@ -151,33 +149,37 @@ final class PrinttargLabelTests: XCTestCase {
|
|||||||
return Calendar(identifier: .gregorian).date(from: comps)!
|
return Calendar(identifier: .gregorian).date(from: comps)!
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAutomatic() {
|
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM")
|
||||||
|
func automatic() {
|
||||||
let label = PrinttargLabel.automatic(
|
let label = PrinttargLabel.automatic(
|
||||||
basename: "tgt",
|
basename: "tgt",
|
||||||
metadata: TargetLabelMetadata(
|
metadata: TargetLabelMetadata(
|
||||||
printer: "Epson", inkSet: "CMYK",
|
printer: "Epson", inkSet: "CMYK",
|
||||||
driverPaper: "Photo", actualPaper: "Matte"),
|
driverPaper: "Photo", actualPaper: "Matte"),
|
||||||
date: fixedDate, timeZone: .current)
|
date: fixedDate, timeZone: .current)
|
||||||
XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
|
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
|
||||||
XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
|
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnspecified() {
|
@Test("Missing metadata becomes Unspecified")
|
||||||
|
func unspecified() {
|
||||||
let label = PrinttargLabel.automatic(
|
let label = PrinttargLabel.automatic(
|
||||||
basename: "tgt", metadata: TargetLabelMetadata(),
|
basename: "tgt", metadata: TargetLabelMetadata(),
|
||||||
date: fixedDate, timeZone: .current)
|
date: fixedDate, timeZone: .current)
|
||||||
XCTAssertTrue(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
|
#expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testManualWins() {
|
@Test("Manual label wins over automatic")
|
||||||
|
func manualWins() {
|
||||||
let resolved = PrinttargLabel.resolved(
|
let resolved = PrinttargLabel.resolved(
|
||||||
customLabel: " My Label ", basename: "tgt",
|
customLabel: " My Label ", basename: "tgt",
|
||||||
metadata: TargetLabelMetadata(), date: fixedDate)
|
metadata: TargetLabelMetadata(), date: fixedDate)
|
||||||
XCTAssertEqual(resolved, "My Label")
|
#expect(resolved == "My Label")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PrinttargManifestTests: XCTestCase {
|
@Suite("PrinttargManifest")
|
||||||
|
struct PrinttargManifestTests {
|
||||||
|
|
||||||
private let prettySingle = """
|
private let prettySingle = """
|
||||||
Some log line
|
Some log line
|
||||||
@@ -206,52 +208,59 @@ final class PrinttargManifestTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
func testSinglePage() throws {
|
@Test("Decodes a single-page pretty manifest amid log noise")
|
||||||
|
func singlePage() throws {
|
||||||
let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
|
let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
|
||||||
XCTAssertEqual(m.event, "manifest")
|
#expect(m.event == "manifest")
|
||||||
XCTAssertEqual(m.pages.count, 1)
|
#expect(m.pages.count == 1)
|
||||||
XCTAssertEqual(m.pages[0].filename, "target.tif")
|
#expect(m.pages[0].filename == "target.tif")
|
||||||
XCTAssertEqual(m.pages[0].patches, 800)
|
#expect(m.pages[0].patches == 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultiPage() throws {
|
@Test("Multi-page manifest preserves order")
|
||||||
|
func multiPage() throws {
|
||||||
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
|
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
|
||||||
XCTAssertEqual(m.pages.map(\.filename), ["p1.tif", "p2.tif"])
|
#expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNoJSON() {
|
@Test("No JSON document → noJSONDocument")
|
||||||
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in
|
func noJSON() {
|
||||||
XCTAssertTrue(error is ManifestError)
|
#expect(throws: ManifestError.self) {
|
||||||
|
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWrongEvent() {
|
@Test("Wrong event → wrongEvent")
|
||||||
|
func wrongEvent() {
|
||||||
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
|
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
|
||||||
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
|
#expect(throws: ManifestError.self) {
|
||||||
XCTAssertTrue(error is ManifestError)
|
try PrinttargManifestExtractor.manifest(from: stdout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRowColorsNotManifest() {
|
@Test("ROW_COLORS_JSON line is never treated as the manifest")
|
||||||
|
func rowColorsNotManifest() {
|
||||||
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
|
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
|
||||||
// Extraction only starts at a '{' that begins a trimmed line,
|
// Extraction only starts at a '{' that begins a trimmed line,
|
||||||
// so the ROW_COLORS_JSON line is skipped entirely.
|
// so the ROW_COLORS_JSON line is skipped entirely.
|
||||||
let m = try? PrinttargManifestExtractor.manifest(from: stdout)
|
let m = try? PrinttargManifestExtractor.manifest(from: stdout)
|
||||||
XCTAssertNotNil(m)
|
#expect(m != nil)
|
||||||
XCTAssertEqual(m?.event, "manifest")
|
#expect(m?.event == "manifest")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBracesInFilename() throws {
|
@Test("Braces inside a quoted filename do not corrupt the scan")
|
||||||
|
func bracesInFilename() throws {
|
||||||
let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n"
|
let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n"
|
||||||
let m = try PrinttargManifestExtractor.manifest(from: stdout)
|
let m = try PrinttargManifestExtractor.manifest(from: stdout)
|
||||||
XCTAssertEqual(m.pages[0].filename, "a}b.tif")
|
#expect(m.pages[0].filename == "a}b.tif")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnsafeFilenames() {
|
@Test("Unsafe / non-TIFF filenames rejected")
|
||||||
|
func unsafeFilenames() {
|
||||||
for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] {
|
for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] {
|
||||||
let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}"
|
let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}"
|
||||||
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
|
#expect(throws: ManifestError.self) {
|
||||||
XCTAssertTrue(error is ManifestError)
|
try PrinttargManifestExtractor.manifest(from: stdout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +349,7 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Non-zero exit throws toolFailed and stays on stage")
|
@Test("Non-zero exit throws processFailed and stays on stage")
|
||||||
func failure() async throws {
|
func failure() async throws {
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
@@ -351,8 +360,7 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await #expect(throws: ArgyllRunnerError.self) {
|
||||||
tool: "printtarg", code: 3, logs: ["oops"])) {
|
|
||||||
try await runner.runPrinttarg(
|
try await runner.runPrinttarg(
|
||||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Testing
|
import Testing
|
||||||
import XCTest
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@@ -409,62 +408,65 @@ struct ProcessManagerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ProcessLineDecoderTests: XCTestCase {
|
@Suite("ProcessLineDecoder")
|
||||||
func testSplitsAcrossChunkBoundaries() {
|
struct ProcessLineDecoderTests {
|
||||||
|
@Test func splitsAcrossChunkBoundaries() {
|
||||||
var d = ProcessLineDecoder()
|
var d = ProcessLineDecoder()
|
||||||
XCTAssertEqual(d.feed(Data("he".utf8)), [])
|
#expect(d.feed(Data("he".utf8)) == [])
|
||||||
XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"])
|
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
|
||||||
XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"])
|
#expect(d.feed(Data("ld\n".utf8)) == ["world"])
|
||||||
XCTAssertNil(d.finish())
|
#expect(d.finish() == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCrlfIsStripped() {
|
@Test func crlfIsStripped() {
|
||||||
var d = ProcessLineDecoder()
|
var d = ProcessLineDecoder()
|
||||||
XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"])
|
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFinishReturnsRemainder() {
|
@Test func finishReturnsRemainder() {
|
||||||
var d = ProcessLineDecoder()
|
var d = ProcessLineDecoder()
|
||||||
_ = d.feed(Data("x".utf8))
|
_ = d.feed(Data("x".utf8))
|
||||||
XCTAssertEqual(d.finish(), "x")
|
#expect(d.finish() == "x")
|
||||||
XCTAssertNil(d.finish())
|
#expect(d.finish() == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class JSONAccumulatorTests: XCTestCase {
|
@Suite("JSONAccumulator")
|
||||||
func testMultilinePrettyJSON() {
|
struct JSONAccumulatorTests {
|
||||||
|
@Test func multilinePrettyJSON() {
|
||||||
var acc = JSONAccumulator()
|
var acc = JSONAccumulator()
|
||||||
XCTAssertNil(acc.feed(line: "{"))
|
#expect(acc.feed(line: "{") == nil)
|
||||||
XCTAssertNil(acc.feed(line: " \"k\": 1"))
|
#expect(acc.feed(line: " \"k\": 1") == nil)
|
||||||
let done = acc.feed(line: "}")
|
let done = acc.feed(line: "}")
|
||||||
XCTAssertNotNil(done)
|
#expect(done != nil)
|
||||||
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
|
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
|
||||||
XCTAssertEqual(obj?["k"], 1)
|
#expect(obj?["k"] == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNonJSONLinesIgnored() {
|
@Test func nonJSONLinesIgnored() {
|
||||||
var acc = JSONAccumulator()
|
var acc = JSONAccumulator()
|
||||||
XCTAssertNil(acc.feed(line: "Reading instrument..."))
|
#expect(acc.feed(line: "Reading instrument...") == nil)
|
||||||
XCTAssertNil(acc.feed(line: "still text"))
|
#expect(acc.feed(line: "still text") == nil)
|
||||||
XCTAssertNil(acc.completeData)
|
#expect(acc.completeData == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDecodeTyped() {
|
@Test func decodeTyped() {
|
||||||
struct Doc: Decodable { let n: Int }
|
struct Doc: Decodable { let n: Int }
|
||||||
var acc = JSONAccumulator()
|
var acc = JSONAccumulator()
|
||||||
// Split so the doc completes on the second feed.
|
// Split so the doc completes on the second feed.
|
||||||
XCTAssertNil(acc.feed(line: "{\"n\":"))
|
#expect(acc.feed(line: "{\"n\":") == nil)
|
||||||
let data = acc.feed(line: "7}")
|
let data = acc.feed(line: "7}")
|
||||||
XCTAssertNotNil(data)
|
#expect(data != nil)
|
||||||
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
|
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
|
||||||
XCTAssertEqual(doc?.n, 7)
|
#expect(doc?.n == 7)
|
||||||
XCTAssertTrue(acc.isEmpty)
|
#expect(acc.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class LogSanitizerTests: XCTestCase {
|
@Suite("LogSanitizer")
|
||||||
func testHomeIsRewritten() {
|
struct LogSanitizerTests {
|
||||||
|
@Test func homeIsRewritten() {
|
||||||
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
|
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
|
||||||
XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1")
|
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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,15 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class ProfcheckArgsTests: XCTestCase {
|
@Suite("ProfcheckArgs")
|
||||||
|
struct ProfcheckArgsTests {
|
||||||
|
|
||||||
func testArgv() throws {
|
@Test("Hard-coded argv")
|
||||||
|
func argv() throws {
|
||||||
let config = ProfcheckConfig(
|
let config = ProfcheckConfig(
|
||||||
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
|
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
|
||||||
iccURL: URL(fileURLWithPath: "/tmp/target.icc")
|
iccURL: URL(fileURLWithPath: "/tmp/target.icc")
|
||||||
)
|
)
|
||||||
let args = try ProfcheckArgs.build(config: config)
|
let args = try ProfcheckArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
|
#expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,43 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class ProfcheckParserTests: XCTestCase {
|
@Suite("ProfcheckParser")
|
||||||
|
struct ProfcheckParserTests {
|
||||||
|
|
||||||
func testJsonReport() {
|
@Test("Prefers JSON report with de2000 keys")
|
||||||
|
func jsonReport() {
|
||||||
let output = """
|
let output = """
|
||||||
No of test patches = 52
|
No of test patches = 52
|
||||||
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||||
Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99
|
Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99
|
||||||
"""
|
"""
|
||||||
let report = ProfcheckParser.parse(output)
|
let report = ProfcheckParser.parse(output)
|
||||||
XCTAssertEqual(report.isValid, true)
|
#expect(report.isValid == true)
|
||||||
XCTAssertEqual(report.patchCount, 52)
|
#expect(report.patchCount == 52)
|
||||||
XCTAssertEqual(report.avgDE, 0.85)
|
#expect(report.avgDE == 0.85)
|
||||||
XCTAssertEqual(report.maxDE, 2.41)
|
#expect(report.maxDE == 2.41)
|
||||||
XCTAssertEqual(report.rmsDE, 1.02)
|
#expect(report.rmsDE == 1.02)
|
||||||
XCTAssertEqual(report.status, .excellent)
|
#expect(report.status == .excellent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLegacyText() {
|
@Test("Falls back to legacy text")
|
||||||
|
func legacyText() {
|
||||||
let output = """
|
let output = """
|
||||||
No of test patches = 120
|
No of test patches = 120
|
||||||
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
|
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
|
||||||
"""
|
"""
|
||||||
let report = ProfcheckParser.parse(output)
|
let report = ProfcheckParser.parse(output)
|
||||||
XCTAssertEqual(report.isValid, true)
|
#expect(report.isValid == true)
|
||||||
XCTAssertEqual(report.patchCount, 120)
|
#expect(report.patchCount == 120)
|
||||||
XCTAssertEqual(report.avgDE, 1.80)
|
#expect(report.avgDE == 1.80)
|
||||||
XCTAssertEqual(report.maxDE, 3.50)
|
#expect(report.maxDE == 3.50)
|
||||||
XCTAssertEqual(report.rmsDE, 0.95)
|
#expect(report.rmsDE == 0.95)
|
||||||
XCTAssertEqual(report.status, .good)
|
#expect(report.status == .good)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRegexFallback() {
|
@Test("Broad regex fallback")
|
||||||
|
func regexFallback() {
|
||||||
let output = """
|
let output = """
|
||||||
No of test patches = 10
|
No of test patches = 10
|
||||||
avg = 4.25
|
avg = 4.25
|
||||||
@@ -41,25 +45,27 @@ final class ProfcheckParserTests: XCTestCase {
|
|||||||
rms = 2.30
|
rms = 2.30
|
||||||
"""
|
"""
|
||||||
let report = ProfcheckParser.parse(output)
|
let report = ProfcheckParser.parse(output)
|
||||||
XCTAssertEqual(report.isValid, true)
|
#expect(report.isValid == true)
|
||||||
XCTAssertEqual(report.avgDE, 4.25)
|
#expect(report.avgDE == 4.25)
|
||||||
XCTAssertEqual(report.maxDE, 6.10)
|
#expect(report.maxDE == 6.10)
|
||||||
XCTAssertEqual(report.rmsDE, 2.30)
|
#expect(report.rmsDE == 2.30)
|
||||||
XCTAssertEqual(report.status, .poor)
|
#expect(report.status == .poor)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnparseable() {
|
@Test("Unparseable output warns, not zeros")
|
||||||
|
func unparseable() {
|
||||||
let output = "some random text without metrics"
|
let output = "some random text without metrics"
|
||||||
let report = ProfcheckParser.parse(output)
|
let report = ProfcheckParser.parse(output)
|
||||||
XCTAssertEqual(report.isValid, false)
|
#expect(report.isValid == false)
|
||||||
XCTAssertNotNil(report.warning)
|
#expect(report.warning != nil)
|
||||||
XCTAssertNil(report.avgDE)
|
#expect(report.avgDE == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testStatusBands() {
|
@Test("Status bands")
|
||||||
XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent)
|
func statusBands() {
|
||||||
XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good)
|
#expect(VerificationStatus.from(avgDE: 0.5) == .excellent)
|
||||||
XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable)
|
#expect(VerificationStatus.from(avgDE: 1.5) == .good)
|
||||||
XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor)
|
#expect(VerificationStatus.from(avgDE: 2.5) == .acceptable)
|
||||||
|
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Testing
|
import Testing
|
||||||
import XCTest
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class TargenArgsTests: XCTestCase {
|
@Suite("TargenArgs")
|
||||||
|
struct TargenArgsTests {
|
||||||
|
|
||||||
func testRgbBaseline() throws {
|
@Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4")
|
||||||
|
func rgbBaseline() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 800,
|
patchCount: 800,
|
||||||
@@ -14,11 +15,12 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
basename: "test_rgb"
|
basename: "test_rgb"
|
||||||
)
|
)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
|
#expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
|
||||||
XCTAssertFalse(args.contains("-u"))
|
#expect(!args.contains("-u"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCmykBaseline() throws {
|
@Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0")
|
||||||
|
func cmykBaseline() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .cmyk,
|
colourSpace: .cmyk,
|
||||||
patchCount: 1500,
|
patchCount: 1500,
|
||||||
@@ -27,10 +29,11 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
basename: "test_cmyk"
|
basename: "test_cmyk"
|
||||||
)
|
)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
XCTAssertEqual(args, ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
|
#expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCustomPatchCount() throws {
|
@Test("Custom patch count honours -f (#44)")
|
||||||
|
func customPatchCount() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 2500,
|
patchCount: 2500,
|
||||||
@@ -39,11 +42,12 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
basename: "custom_patches"
|
basename: "custom_patches"
|
||||||
)
|
)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
XCTAssertTrue(args.contains("-f"))
|
#expect(args.contains("-f"))
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500")
|
#expect(args[args.firstIndex(of: "-f")! + 1] == "2500")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAllAdvancedFlags() throws {
|
@Test("All advanced flags in stable order")
|
||||||
|
func allAdvancedFlags() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .cmyk,
|
colourSpace: .cmyk,
|
||||||
patchCount: 1200,
|
patchCount: 1200,
|
||||||
@@ -81,10 +85,11 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
"-p", "2.00",
|
"-p", "2.00",
|
||||||
"advanced_cmyk"
|
"advanced_cmyk"
|
||||||
]
|
]
|
||||||
XCTAssertEqual(args, expected)
|
#expect(args == expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRgbIgnoresInkLimit() throws {
|
@Test("RGB ignores total ink limit")
|
||||||
|
func rgbIgnoresInkLimit() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 800,
|
patchCount: 800,
|
||||||
@@ -94,10 +99,11 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
basename: "rgb_no_ink"
|
basename: "rgb_no_ink"
|
||||||
)
|
)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("-l"))
|
#expect(!args.contains("-l"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNeutralConcentrationOmittedWhenDefault() throws {
|
@Test("Neutral concentration omitted when approximately 0.50")
|
||||||
|
func neutralConcentrationOmittedWhenDefault() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 800,
|
patchCount: 800,
|
||||||
@@ -107,10 +113,11 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
basename: "n_default"
|
basename: "n_default"
|
||||||
)
|
)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("-N"))
|
#expect(!args.contains("-N"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAdaptationEmittedAtPointOne() throws {
|
@Test("Adaptation emitted even at 0.10 (no default-skip)")
|
||||||
|
func adaptationEmittedAtPointOne() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 800,
|
patchCount: 800,
|
||||||
@@ -120,11 +127,12 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
basename: "a_flag"
|
basename: "a_flag"
|
||||||
)
|
)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
XCTAssertTrue(args.contains("-A"))
|
#expect(args.contains("-A"))
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10")
|
#expect(args[args.firstIndex(of: "-A")! + 1] == "0.10")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOfpsEmitsNoFlag() throws {
|
@Test("OFPS full spread algorithm emits no flag")
|
||||||
|
func ofpsEmitsNoFlag() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 800,
|
patchCount: 800,
|
||||||
@@ -134,11 +142,12 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
basename: "ofps_test"
|
basename: "ofps_test"
|
||||||
)
|
)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("ofps"))
|
#expect(!args.contains("ofps"))
|
||||||
XCTAssertFalse(args.contains("-t"))
|
#expect(!args.contains("-t"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDarkEmphasisAndPowerOmittedWhenOne() throws {
|
@Test("Dark emphasis and device power omitted when 1.0")
|
||||||
|
func darkEmphasisAndPowerOmittedWhenOne() throws {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 800,
|
patchCount: 800,
|
||||||
@@ -149,37 +158,12 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
basename: "defaults_omitted"
|
basename: "defaults_omitted"
|
||||||
)
|
)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
XCTAssertFalse(args.contains("-V"))
|
#expect(!args.contains("-V"))
|
||||||
XCTAssertFalse(args.contains("-p"))
|
#expect(!args.contains("-p"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWhitespacePreconditioner() throws {
|
@Test("Invalid basename throws")
|
||||||
let config = TargenConfig(
|
func invalidBasenameThrows() {
|
||||||
colourSpace: .rgb,
|
|
||||||
patchCount: 800,
|
|
||||||
whitePatches: 4,
|
|
||||||
blackPatches: 4,
|
|
||||||
preconditioningProfile: " \n\t ",
|
|
||||||
basename: "ws_pre"
|
|
||||||
)
|
|
||||||
let args = try TargenArgs.build(config: config)
|
|
||||||
XCTAssertFalse(args.contains("-c"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPreconditionerTrimmed() throws {
|
|
||||||
let config = TargenConfig(
|
|
||||||
colourSpace: .rgb,
|
|
||||||
patchCount: 800,
|
|
||||||
whitePatches: 4,
|
|
||||||
blackPatches: 4,
|
|
||||||
preconditioningProfile: " /path/to/profile.icc ",
|
|
||||||
basename: "trim_pre"
|
|
||||||
)
|
|
||||||
let args = try TargenArgs.build(config: config)
|
|
||||||
XCTAssertEqual(args[args.firstIndex(of: "-c")! + 1], "/path/to/profile.icc")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testInvalidBasenameThrows() {
|
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 800,
|
patchCount: 800,
|
||||||
@@ -187,12 +171,13 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
blackPatches: 4,
|
blackPatches: 4,
|
||||||
basename: "../bad_name"
|
basename: "../bad_name"
|
||||||
)
|
)
|
||||||
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
|
#expect(throws: PathSecurity.Error.self) {
|
||||||
XCTAssertTrue(error is PathSecurity.Error)
|
try TargenArgs.build(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInvalidPatchCountThrows() {
|
@Test("Invalid patch count throws")
|
||||||
|
func invalidPatchCountThrows() {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
patchCount: 0,
|
patchCount: 0,
|
||||||
@@ -200,12 +185,13 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
blackPatches: 4,
|
blackPatches: 4,
|
||||||
basename: "bad_count"
|
basename: "bad_count"
|
||||||
)
|
)
|
||||||
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
|
#expect(throws: TargenArgError.self) {
|
||||||
XCTAssertTrue(error is TargenArgError)
|
try TargenArgs.build(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInvalidInkLimitThrows() {
|
@Test("Invalid ink limit throws for CMYK")
|
||||||
|
func invalidInkLimitThrows() {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
colourSpace: .cmyk,
|
colourSpace: .cmyk,
|
||||||
patchCount: 800,
|
patchCount: 800,
|
||||||
@@ -214,8 +200,8 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
totalInkLimit: 450,
|
totalInkLimit: 450,
|
||||||
basename: "bad_ink"
|
basename: "bad_ink"
|
||||||
)
|
)
|
||||||
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
|
#expect(throws: TargenArgError.self) {
|
||||||
XCTAssertTrue(error is TargenArgError)
|
try TargenArgs.build(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,7 +262,7 @@ struct ArgyllRunnerTargenTests {
|
|||||||
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failed targen execution throws toolFailed")
|
@Test("Failed targen execution throws processFailed")
|
||||||
func failedTargenExecution() async throws {
|
func failedTargenExecution() async throws {
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
@@ -304,8 +290,7 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await #expect(throws: ArgyllRunnerError.self) {
|
||||||
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
|
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,8 +323,7 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.missingArtefact(
|
await #expect(throws: ArgyllRunnerError.self) {
|
||||||
tempDir.appendingPathComponent("no_file.ti1").path)) {
|
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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,3 +1,4 @@
|
|||||||
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
extension XCTestCase {
|
|
||||||
func assertAsyncThrows<T, E: Error>(
|
|
||||||
expectedType: E.Type,
|
|
||||||
_ expression: () async throws -> T,
|
|
||||||
_ message: @autoclosure () -> String = "",
|
|
||||||
file: StaticString = #filePath,
|
|
||||||
line: UInt = #line,
|
|
||||||
errorHandler: ((E) -> Void)? = nil
|
|
||||||
) async {
|
|
||||||
do {
|
|
||||||
_ = try await expression()
|
|
||||||
XCTFail("Expected \(expectedType) to be thrown but expression succeeded. \(message())", file: file, line: line)
|
|
||||||
} catch let error as E {
|
|
||||||
errorHandler?(error)
|
|
||||||
} catch {
|
|
||||||
XCTFail("Expected \(expectedType) but caught \(type(of: error)): \(error). \(message())", file: file, line: line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -133,18 +133,6 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
|
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 1/2 process-log containers resolve under the shared
|
|
||||||
/// `ProcessLogView` identifiers (issue #80).
|
|
||||||
func testProcessLogContainersResolve() throws {
|
|
||||||
launchApp()
|
|
||||||
XCTAssertTrue(waitFor("targenLogContainer").exists)
|
|
||||||
|
|
||||||
app.buttons["btnBrowse"].click()
|
|
||||||
app.buttons["btnGenerate"].click()
|
|
||||||
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
|
|
||||||
XCTAssertTrue(element("printtargLogContainer").exists)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
|
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
|
||||||
func testTargenFixtureUnlocksStage2() throws {
|
func testTargenFixtureUnlocksStage2() throws {
|
||||||
launchApp()
|
launchApp()
|
||||||
|
|||||||
@@ -146,8 +146,6 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
XCTAssertTrue((notice.value as? String ?? "")
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
.contains("cancelled"))
|
.contains("cancelled"))
|
||||||
// Cancellation is informational, never an error (#80).
|
|
||||||
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferences OK → captured options are replayed verbatim in the
|
/// Preferences OK → captured options are replayed verbatim in the
|
||||||
@@ -216,8 +214,6 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
let notice = app.staticTexts.containing(predicate).firstMatch
|
let notice = app.staticTexts.containing(predicate).firstMatch
|
||||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
XCTAssertTrue(notice.label.contains("Print failed"))
|
XCTAssertTrue(notice.label.contains("Print failed"))
|
||||||
// Spool failure exposes the .error kind on the icon (#80).
|
|
||||||
XCTAssertEqual(element("printNotificationIcon").value as? String, "error")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// wizardState.printerName records the queue used for spooling (#95).
|
/// wizardState.printerName records the queue used for spooling (#95).
|
||||||
|
|||||||
@@ -140,57 +140,4 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two passes + a failing `average` run promote pass 1 to the
|
|
||||||
/// canonical .ti3 and show the sticky finish error notice via
|
|
||||||
/// `chartreadFinishNotice` (issue #80).
|
|
||||||
func testTwoPassAverageFailurePromotesFirstPass() throws {
|
|
||||||
app.launchEnvironment["MOCK_AVERAGE_FAIL"] = "1"
|
|
||||||
reachStage3()
|
|
||||||
|
|
||||||
app.buttons["btnDetectInstruments"].click()
|
|
||||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
|
||||||
|
|
||||||
driveOnePass(startButton: "btnStartRead")
|
|
||||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
|
||||||
|
|
||||||
driveOnePass(startButton: "btnMeasureAnotherSheet")
|
|
||||||
|
|
||||||
XCTAssertTrue(waitFor("btnFinishAndAverage", timeout: 20).exists)
|
|
||||||
app.buttons["btnFinishAndAverage"].click()
|
|
||||||
|
|
||||||
// Averaging failed → pass 1 is promoted to the canonical .ti3
|
|
||||||
// and the sticky error notice stays on Stage 3.
|
|
||||||
let ti3 = workDir.appendingPathComponent("mytarget.ti3")
|
|
||||||
let deadline = Date().addingTimeInterval(20)
|
|
||||||
while Date() < deadline, !FileManager.default.fileExists(atPath: ti3.path) {
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
||||||
}
|
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
|
|
||||||
|
|
||||||
let notice = element("chartreadFinishNotice")
|
|
||||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
|
||||||
XCTAssertEqual(notice.value as? String, "error")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the mock handheld chartread session to completion
|
|
||||||
/// (start → calibrate → strip A → strip B → Done & Save).
|
|
||||||
private func driveOnePass(startButton: String) {
|
|
||||||
let start = app.buttons[startButton]
|
|
||||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
|
||||||
let deadline = Date().addingTimeInterval(10)
|
|
||||||
while Date() < deadline, !start.isEnabled {
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
|
||||||
}
|
|
||||||
XCTAssertTrue(start.isEnabled)
|
|
||||||
start.click()
|
|
||||||
_ = waitFor("btnCalibrate", timeout: 25)
|
|
||||||
app.buttons["btnCalibrate"].click()
|
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
_ = waitFor("btnDoneRead", timeout: 20)
|
|
||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,21 +111,4 @@ final class Milestone5UITests: XCTestCase {
|
|||||||
"Expected verification status, got '\(statusValue)'"
|
"Expected verification status, got '\(statusValue)'"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A failing colprof run surfaces through the session-wide wizard
|
|
||||||
/// notice only — no duplicate stage-local error view (issue #80).
|
|
||||||
func testProfileFailureShowsWizardNotice() throws {
|
|
||||||
app.launchEnvironment["ICCERY_MOCK_COLPROF_EXIT"] = "2"
|
|
||||||
launchApp()
|
|
||||||
|
|
||||||
let create = waitFor("btnCreateProfile")
|
|
||||||
XCTAssertTrue(create.isEnabled)
|
|
||||||
create.click()
|
|
||||||
|
|
||||||
let notice = element("noticeText")
|
|
||||||
XCTAssertTrue(notice.waitForExistence(timeout: 20))
|
|
||||||
XCTAssertTrue((notice.value as? String ?? "")
|
|
||||||
.contains("Profile creation failed"))
|
|
||||||
XCTAssertFalse(element("colprofLastError").exists)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
|||||||
"ICCERY_TEST_WORKDIR": testWorkDir.path
|
"ICCERY_TEST_WORKDIR": testWorkDir.path
|
||||||
]
|
]
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
@@ -78,60 +77,4 @@ 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
@@ -2,7 +2,7 @@ name: ICCery
|
|||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.gronod
|
bundleIdPrefix: com.gronod
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
macOS: "12.0"
|
macOS: "14.0"
|
||||||
groupSortPosition: top
|
groupSortPosition: top
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@@ -13,7 +13,7 @@ targets:
|
|||||||
ICCery:
|
ICCery:
|
||||||
type: application
|
type: application
|
||||||
platform: macOS
|
platform: macOS
|
||||||
deploymentTarget: "12.0"
|
deploymentTarget: "14.0"
|
||||||
sources:
|
sources:
|
||||||
- path: Sources/ICCery
|
- path: Sources/ICCery
|
||||||
- path: Resources
|
- path: Resources
|
||||||
@@ -46,7 +46,7 @@ targets:
|
|||||||
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
|
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: ICCery
|
INFOPLIST_KEY_CFBundleDisplayName: ICCery
|
||||||
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
|
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
|
||||||
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||||
MARKETING_VERSION: "2.0.0"
|
MARKETING_VERSION: "2.0.0"
|
||||||
@@ -58,15 +58,15 @@ targets:
|
|||||||
ENABLE_APP_SANDBOX: NO
|
ENABLE_APP_SANDBOX: NO
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "6.0"
|
||||||
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
ARCHS: "$(ARCHS_STANDARD)"
|
ARCHS: "$(ARCHS_STANDARD)"
|
||||||
|
|
||||||
ICCeryCoreTests:
|
ICCeryCoreTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: macOS
|
platform: macOS
|
||||||
deploymentTarget: "12.0"
|
deploymentTarget: "14.0"
|
||||||
sources:
|
sources:
|
||||||
- path: Tests/ICCeryCoreTests
|
- path: Tests/ICCeryCoreTests
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -79,13 +79,13 @@ targets:
|
|||||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
CODE_SIGN_IDENTITY: "-"
|
CODE_SIGN_IDENTITY: "-"
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "6.0"
|
||||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
|
||||||
ICCeryUITests:
|
ICCeryUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
platform: macOS
|
platform: macOS
|
||||||
deploymentTarget: "12.0"
|
deploymentTarget: "14.0"
|
||||||
sources:
|
sources:
|
||||||
- path: Tests/ICCeryUITests
|
- path: Tests/ICCeryUITests
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -95,8 +95,8 @@ targets:
|
|||||||
TEST_TARGET_NAME: ICCery
|
TEST_TARGET_NAME: ICCery
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
CODE_SIGN_IDENTITY: "-"
|
CODE_SIGN_IDENTITY: "-"
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "6.0"
|
||||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
ICCery:
|
ICCery:
|
||||||
|
|||||||
Reference in New Issue
Block a user