Compare commits

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

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

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 20:02:57 +01:00
gronod 09f433106d Merge pull request 'Milestone 8 — UAT-ready hardening and consolidation (#79–#86)' (#104) from milestone/m8-consolidation into develop
macOS CI / build-and-test (push) Successful in 13m1s
macOS CI / package (push) Successful in 4m25s
2026-09-11 17:10:23 +01:00
35 changed files with 811 additions and 1015 deletions
+10 -2
View File
@@ -14,13 +14,21 @@ jobs:
build-and-test: build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not, # Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline. # macos-14 works for this pipeline.
runs-on: macos-14 runs-on: macos-12
env: env:
DERIVED: build/DerivedData-test DERIVED: build/DerivedData-test
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Assert Xcode 14 toolchain
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
- name: Ensure host tools
run: |
command -v xcodegen || brew install xcodegen
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
- name: Generate Xcode project - name: Generate Xcode project
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
@@ -120,7 +128,7 @@ jobs:
package: package:
needs: build-and-test needs: build-and-test
runs-on: macos-14 runs-on: macos-12
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Checkout - name: Checkout
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "ICCeryCore", name: "ICCeryCore",
platforms: [.macOS(.v14)], platforms: [.macOS(.v12)],
products: [ products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]), .library(name: "ICCeryCore", targets: ["ICCeryCore"]),
], ],
@@ -200,7 +200,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id) await processManager.kill(id: id)
var attempts = 0 var attempts = 0
while await processManager.isRunning(id), attempts < 30 { while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100)) try? await Task.sleep(nanoseconds: 100_000_000)
attempts += 1 attempts += 1
} }
} }
@@ -243,7 +243,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines { if flushPartialLines {
dotFlushTask = Task { [processManager] in dotFlushTask = Task { [processManager] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500)) try? await Task.sleep(nanoseconds: 500_000_000)
if Task.isCancelled { break } if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId) await processManager.flushPartialLine(id: processId)
} }
@@ -592,7 +592,7 @@ public struct ArgyllRunner: Sendable {
await processManager.setPreKillHook(id: processId) { [processManager] in await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY { if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(for: .milliseconds(500)) try? await Task.sleep(nanoseconds: 500_000_000)
} }
} }
@@ -72,8 +72,8 @@ public enum CGATSParser {
public static func parse( public static func parse(
_ contents: String, _ contents: String,
sourceURL: URL? = nil sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset { ) throws -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile } guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? "" let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines) let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -101,10 +101,10 @@ public enum CGATSParser {
} }
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else { guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat throw CGATSParseError.missingBeginDataFormat
} }
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else { guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData throw CGATSParseError.missingBeginData
} }
let rawFieldNames = splitFields(lines[formatStart + 1]) let rawFieldNames = splitFields(lines[formatStart + 1])
@@ -139,7 +139,7 @@ public enum CGATSParser {
let lineIndex = dataStart + offset let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex]) let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else { guard rawRow.count == fieldNames.count else {
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count) throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
} }
var sample = RawSample(id: String(offset), lineIndex: lineIndex) var sample = RawSample(id: String(offset), lineIndex: lineIndex)
@@ -153,7 +153,7 @@ public enum CGATSParser {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number) groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
} }
} else if !cleaned.isEmpty { } else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1) throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
} }
} else { } else {
sample.strings[name] = raw sample.strings[name] = raw
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess( private static func preprocess(
_ contents: String, _ contents: String,
isCSV: Bool isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) { ) throws -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines) let allLines = contents.components(separatedBy: .newlines)
var lines = [String]() var lines = [String]()
@@ -239,7 +239,7 @@ public enum CGATSParser {
lines.append(line) lines.append(line)
} }
guard !lines.isEmpty else { throw .emptyFile } guard !lines.isEmpty else { throw CGATSParseError.emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block // Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it. // structure so the boundary-based parser below can handle it.
@@ -559,7 +559,7 @@ public actor ProcessManager {
// Start a watchdog in case the `readabilityHandler` EOFs never // Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe). // arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in child.finalizeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(nanoseconds: 2_000_000_000)
guard let self else { return } guard let self else { return }
await self.forceKill(id: id) await self.forceKill(id: id)
await self.forceFinalize(id: id) await self.forceFinalize(id: id)
+1 -1
View File
@@ -33,7 +33,7 @@ final class SettingsViewModel {
sink.applySettings(settings) sink.applySettings(settings)
savedFlash = true savedFlash = true
Task { Task {
try? await Task.sleep(for: .seconds(1.5)) try? await Task.sleep(nanoseconds: 1_500_000_000)
savedFlash = false savedFlash = false
} }
return true return true
+1 -1
View File
@@ -201,7 +201,7 @@ final class WizardViewModel {
self.notice = notice self.notice = notice
if let delay = notice.autoHideAfter { if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in noticeDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay)) try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id { if self?.notice?.id == notice.id {
self?.notice = nil self?.notice = nil
+13 -15
View File
@@ -1,27 +1,25 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("AppPaths") final class AppPathsTests: XCTestCase {
struct AppPathsTests { func testAppDataDirUsesBundleID() {
@Test func appDataDirUsesBundleID() { XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
} }
@Test func logFileIsUnderLibraryLogs() { func testLogFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log") XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2")) XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
} }
@Test func bundledArgyllDirIsInsideResources() { func testBundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll") XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
} }
} }
@Suite("WizardStage") final class WizardStageTests: XCTestCase {
struct WizardStageTests { func testStepperOrderIsOneThroughFive() {
@Test func stepperOrderIsOneThroughFive() { XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5]) XCTAssertNil(WizardStage.calibrate.stepperIndex)
#expect(WizardStage.calibrate.stepperIndex == nil)
} }
} }
@@ -1,22 +1,19 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ApplycalArgs") final class ApplycalArgsTests: XCTestCase {
struct ApplycalArgsTests {
@Test("Apply argv") func testApplyArgv() throws {
func applyArgv() throws {
let config = ApplycalConfig( let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal", calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc") inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
) )
let args = try ApplycalArgs.build(config: config) let args = try ApplycalArgs.build(config: config)
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"]) XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
} }
@Test("Unapply is emitted when the caller explicitly sets it") func testUnapplyEmittedWhenConfigSet() throws {
func unapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig( let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal", calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"), inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
@@ -25,6 +22,6 @@ struct ApplycalArgsTests {
let args = try ApplycalArgs.build(config: config) let args = try ApplycalArgs.build(config: config)
// Builder emits -u only when the caller explicitly sets unapply. // Builder emits -u only when the caller explicitly sets unapply.
// The UI layer never passes unapply: true in v2.0. // The UI layer never passes unapply: true in v2.0.
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"]) XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
} }
} }
+41 -55
View File
@@ -1,94 +1,80 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ArgsBuilder") final class ArgsBuilderTests: XCTestCase {
struct ArgsBuilderTests {
// MARK: - option // MARK: - option
@Test("option: nil emits nothing") func testOptionNil() {
func optionNil() { XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
#expect(ArgsBuilder.option("-f", nil) == [])
} }
@Test("option: present value emits flag and value verbatim") func testOptionPresent() {
func optionPresent() { XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
#expect(ArgsBuilder.option("-f", "abc") == ["-f", "abc"]) XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
#expect(ArgsBuilder.option("-f", "") == ["-f", ""]) XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
#expect(ArgsBuilder.option("-f", " padded ") == ["-f", " padded "])
} }
// MARK: - optionIfNonEmpty // MARK: - optionIfNonEmpty
@Test("optionIfNonEmpty: nil and empty emit nothing") func testOptionIfNonEmptyNilEmpty() {
func optionIfNonEmptyNilEmpty() { XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", nil) == []) XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "") == [])
} }
@Test("optionIfNonEmpty: whitespace-only emits nothing") func testOptionIfNonEmptyWhitespace() {
func optionIfNonEmptyWhitespace() { XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " ") == []) XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " \t\n ") == [])
} }
@Test("optionIfNonEmpty: trims surrounding whitespace") func testOptionIfNonEmptyTrims() {
func optionIfNonEmptyTrims() { XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " label ") == ["-d", "label"]) XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n") == ["-d", "cal.cal"])
} }
// MARK: - optionUnlessApprox // MARK: - optionUnlessApprox
@Test("optionUnlessApprox: nil emits nothing") func testOptionUnlessApproxNil() {
func optionUnlessApproxNil() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
#expect(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50) == [])
} }
@Test("optionUnlessApprox: exact skip value emits nothing") func testOptionUnlessApproxExactSkip() {
func optionUnlessApproxExactSkip() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50) == []) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0) == [])
} }
@Test("optionUnlessApprox: within epsilon emits nothing") func testOptionUnlessApproxWithinEpsilon() {
func optionUnlessApproxWithinEpsilon() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50) == []) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0) == [])
} }
@Test("optionUnlessApprox: outside epsilon emits flag") func testOptionUnlessApproxOutsideEpsilon() {
func optionUnlessApproxOutsideEpsilon() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50) == ["-N", "0.75"]) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0) == ["-V", "1.50"]) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50) == ["-N", "0.50"])
} }
@Test("optionUnlessApprox: POSIX formatting is locale-stable") func testOptionUnlessApproxPOSIX() {
func optionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal. // 1234.5 must never produce a grouping separator or comma decimal.
#expect(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0) == ["-p", "1234.50"]) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"])
#expect(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0) == ["-p", "2.00"]) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
} }
@Test("optionUnlessApprox: custom epsilon and format honoured") func testOptionUnlessApproxCustom() {
func optionUnlessApproxCustom() { XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01) == []) XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"])
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f") == ["-x", "1.5"])
} }
// MARK: - flag // MARK: - flag
@Test("flag: true emits the bare flag") func testFlagTrue() {
func flagTrue() { XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
#expect(ArgsBuilder.flag("-G", when: true) == ["-G"]) XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
#expect(ArgsBuilder.flag("-r", when: true) == ["-r"])
} }
@Test("flag: false emits nothing") func testFlagFalse() {
func flagFalse() { XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
#expect(ArgsBuilder.flag("-G", when: false) == []) XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
#expect(ArgsBuilder.flag("-r", when: false) == [])
} }
} }
+46 -60
View File
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
import ImageIO import ImageIO
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL {
.appendingPathComponent(name) .appendingPathComponent(name)
} }
@Suite("Ti2Header") final class Ti2HeaderTests: XCTestCase {
struct Ti2HeaderTests { func testParsesKeywordsAndSibling() throws {
@Test func parsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)") .appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -31,18 +30,18 @@ struct Ti2HeaderTests {
) )
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2")) let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO") XCTAssertEqual(h.instrument, "i1iO")
#expect(h.patchCount == 800) XCTAssertEqual(h.patchCount, 800)
#expect(h.pageCount == 3) XCTAssertEqual(h.pageCount, 3)
#expect(h.hasSiblingTi1) XCTAssertTrue(h.hasSiblingTi1)
} }
@Test func missingFileYieldsEmptyHeader() { func testMissingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2")) let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1) XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
} }
@Test func numberOfFieldsIsNotPatchCount() throws { func testNumberOfFieldsIsNotPatchCount() throws {
let url = tempURL("t.ti2") let url = tempURL("t.ti2")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
@@ -50,12 +49,11 @@ struct Ti2HeaderTests {
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write( try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8 to: url, atomically: true, encoding: .utf8
) )
#expect(Ti2Header.parse(url).patchCount == 52) XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
} }
} }
@Suite("TiffPreview") final class TiffPreviewTests: XCTestCase {
struct TiffPreviewTests {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO. /// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL { private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif") let url = tempURL("big.tif")
@@ -81,50 +79,48 @@ struct TiffPreviewTests {
return url return url
} }
@Test func producesCappedPNG() throws { func testProducesCappedPNG() throws {
let tiff = try makeTiff() let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff) let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil) XCTAssertNotNil(png)
// PNG magic // PNG magic
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
// Verify the cap by decoding the thumbnail header. // Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)! let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)! let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge) XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200) XCTAssertEqual(img.width, 1200)
} }
@Test func nonTiffReturnsNil() throws { func testNonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt") let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "hello".write(to: url, atomically: true, encoding: .utf8) try "hello".write(to: url, atomically: true, encoding: .utf8)
#expect(TiffPreview.previewPNG(tiff: url) == nil) XCTAssertNil(TiffPreview.previewPNG(tiff: url))
} }
} }
@Suite("ArtefactFiles") final class ArtefactFilesTests: XCTestCase {
struct ArtefactFilesTests { func testBase64RoundTrip() throws {
@Test func base64RoundTrip() throws {
let url = tempURL("a.txt") let url = tempURL("a.txt")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "hello".write(to: url, atomically: true, encoding: .utf8) try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url) let b64 = try ArtefactFiles.readBase64(url)
#expect(Data(base64Encoded: b64) == Data("hello".utf8)) XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
} }
@Test func defaultWorkingDirExists() { func testDefaultWorkingDirExists() {
#expect(FileManager.default.fileExists( XCTAssertTrue(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path atPath: ArtefactFiles.defaultWorkingDirectory().path
)) ))
} }
} }
@Suite("ArtefactProbe profile resolve") final class ArtefactProbeProfileTests: XCTestCase {
struct ArtefactProbeProfileTests {
private func makeDir() throws -> URL { private func makeDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)") .appendingPathComponent("probe-\(UUID().uuidString)")
@@ -134,93 +130,83 @@ struct ArtefactProbeProfileTests {
// MARK: Basename probe matrix (#69) // MARK: Basename probe matrix (#69)
@Test("basename probe: only .icc exists") func testOnlyIcc() throws {
func onlyIcc() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc) try Data("icc".utf8).write(to: icc)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path) XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path)
} }
@Test("basename probe: only .icm exists") func testOnlyIcm() throws {
func onlyIcm() throws {
let dir = try makeDir() let dir = try makeDir()
let icm = dir.appendingPathComponent("job.icm") let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm) try Data("icm".utf8).write(to: icm)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path) XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path)
} }
@Test("basename probe prefers .icm") func testIcmWins() throws {
func icmWins() throws {
let dir = try makeDir() let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm") let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm) try Data("icm".utf8).write(to: icm)
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir) let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
#expect(url?.path == icm.path) XCTAssertEqual(url?.path, icm.path)
} }
@Test("basename probe: neither exists returns nil") func testNeitherExists() throws {
func neitherExists() throws {
let dir = try makeDir() let dir = try makeDir()
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil) XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir))
} }
// MARK: Explicit URL matrix (#69 / #83) // MARK: Explicit URL matrix (#69 / #83)
@Test("explicit existing .icc wins even when .icm exists") func testExplicitIccWins() throws {
func explicitIccWins() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc) try Data("icc".utf8).write(to: icc)
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm")) try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path) XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
} }
@Test("explicit existing .icm wins even when .icc exists") func testExplicitIcmWins() throws {
func explicitIcmWins() throws {
let dir = try makeDir() let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm") let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm) try Data("icm".utf8).write(to: icm)
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path) XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path)
} }
@Test("explicit missing .icc flips to sibling .icm") func testFlipExtension() throws {
func flipExtension() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm") let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm) try Data("icm".utf8).write(to: icm)
let resolved = ArtefactProbe.resolveProfile(icc) let resolved = ArtefactProbe.resolveProfile(icc)
#expect(resolved.path == icm.path) XCTAssertEqual(resolved.path, icm.path)
} }
@Test("explicit missing .icm flips to sibling .icc") func testFlipToIcc() throws {
func flipToIcc() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm") let icm = dir.appendingPathComponent("job.icm")
try Data("icc".utf8).write(to: icc) try Data("icc".utf8).write(to: icc)
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path) XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path)
} }
@Test("explicit missing both returns the original URL") func testMissingBoth() throws {
func missingBoth() throws {
let dir = try makeDir() let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path) XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
} }
@Test("unrelated extension is never rewritten") func testUnrelatedExtension() throws {
func unrelatedExtension() throws {
let dir = try makeDir() let dir = try makeDir()
let mpp = dir.appendingPathComponent("job.mpp") let mpp = dir.appendingPathComponent("job.mpp")
let icc = dir.appendingPathComponent("job.icc") let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc) try Data("icc".utf8).write(to: icc)
// Even though a sibling .icc exists, a missing .mpp stays .mpp. // Even though a sibling .icc exists, a missing .mpp stays .mpp.
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path) XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path)
let txt = dir.appendingPathComponent("job.txt") let txt = dir.appendingPathComponent("job.txt")
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path) XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path)
} }
} }
+16 -17
View File
@@ -1,9 +1,8 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("BinaryResolver") final class BinaryResolverTests: XCTestCase {
struct BinaryResolverTests {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL { private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory let root = FileManager.default.temporaryDirectory
@@ -22,16 +21,16 @@ struct BinaryResolverTests {
} }
} }
@Test func overrideDirWinsWhenFileExists() throws { func testOverrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in let override = try makeTree { root in
try touch(root.appendingPathComponent("targen")) try touch(root.appendingPathComponent("targen"))
} }
let bundled = try makeTree { _ in } let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen") == override.appendingPathComponent("targen")) XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen"))
} }
@Test func overrideFallsThroughWhenMissing() throws { func testOverrideFallsThroughWhenMissing() throws {
let override = try makeTree { _ in } let override = try makeTree { _ in }
let bundled = try makeTree { root in let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal") let dir = root.appendingPathComponent("macos-universal")
@@ -39,10 +38,10 @@ struct BinaryResolverTests {
try touch(dir.appendingPathComponent("instlist")) try touch(dir.appendingPathComponent("instlist"))
} }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen").path.contains("macos-universal/targen")) XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen"))
} }
@Test func universalPreferredWhenMarkerPresent() throws { func testUniversalPreferredWhenMarkerPresent() throws {
let bundled = try makeTree { root in let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] { for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir) let d = root.appendingPathComponent(dir)
@@ -51,10 +50,10 @@ struct BinaryResolverTests {
} }
} }
let r = BinaryResolver(bundledRoot: bundled) let r = BinaryResolver(bundledRoot: bundled)
#expect(r.platformDir() == "macos-universal") XCTAssertEqual(r.platformDir(), "macos-universal")
} }
@Test func fallsBackToArchDir() throws { func testFallsBackToArchDir() throws {
let bundled = try makeTree { root in let bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64") let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
@@ -64,20 +63,20 @@ struct BinaryResolverTests {
bundledRoot: bundled, bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"] archDirs: ["macos-universal", "macos-x86_64"]
) )
#expect(r.platformDir() == "macos-x86_64") XCTAssertEqual(r.platformDir(), "macos-x86_64")
} }
@Test func missingEverythingReturnsConstructedPath() throws { func testMissingEverythingReturnsConstructedPath() throws {
let bundled = try makeTree { _ in } let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled) let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error. // v1 semantic: path is returned; spawn surfaces the error.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen")) XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen"))) XCTAssertFalse(r.exists(r.resolve("targen")))
} }
@Test func mockAndGamutPaths() throws { func testMockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x")) let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock") XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam") XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
} }
} }
+37 -50
View File
@@ -1,9 +1,8 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CGATS Parser & Writer") final class CGATSParserTests: XCTestCase {
struct CGATSParserTests {
private static let canonicalCTI3 = """ private static let canonicalCTI3 = """
CTI3 CTI3
@@ -21,44 +20,40 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
@Test("Parses CTI3 with canonical field names") func testParseCTI3() throws {
func parseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3) let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3) XCTAssertEqual(dataset.format, .cti3)
#expect(dataset.samples.count == 2) XCTAssertEqual(dataset.samples.count, 2)
#expect(dataset.colorRep == "RGB") XCTAssertEqual(dataset.colorRep, "RGB")
#expect(dataset.deviceClass == "DISPLAY") XCTAssertEqual(dataset.deviceClass, "DISPLAY")
#expect(dataset.samples[0].id == "1") XCTAssertEqual(dataset.samples[0].id, "1")
#expect(dataset.samples[0].loc == "A1") XCTAssertEqual(dataset.samples[0].loc, "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000") XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
} }
@Test("Round-trips parse, write, reparse") func testRoundTrip() throws {
func roundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3) let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first) let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text) let second = try CGATSParser.parse(text)
#expect(second.format == first.format) XCTAssertEqual(second.format, first.format)
#expect(second.samples.count == first.samples.count) XCTAssertEqual(second.samples.count, first.samples.count)
#expect(second.colorRep == first.colorRep) XCTAssertEqual(second.colorRep, first.colorRep)
#expect(second.deviceClass == first.deviceClass) XCTAssertEqual(second.deviceClass, first.deviceClass)
} }
@Test("Parses CSV with comma delimiters") func testParseCSV() throws {
func parseCSV() throws {
let csv = """ let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30 1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40 2,A2,0,50,0,10,30,5,60,-50,40
""" """
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv")) let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
#expect(dataset.format == .csv) XCTAssertEqual(dataset.format, .csv)
#expect(dataset.samples.count == 2) XCTAssertEqual(dataset.samples.count, 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000") XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
} }
@Test("Converts 0-255 device values to 0-100") func testConverts255To100() throws {
func converts255To100() throws {
let rgb = """ let rgb = """
CTI3 CTI3
COLOR_REP RGB COLOR_REP RGB
@@ -72,12 +67,11 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
let dataset = try CGATSParser.parse(rgb) let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000") XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961") XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
} }
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing") func testSynthesizesMetadata() throws {
func synthesizesMetadata() throws {
let cmyk = """ let cmyk = """
CTI3 CTI3
NUMBER_OF_FIELDS 6 NUMBER_OF_FIELDS 6
@@ -90,19 +84,15 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
let dataset = try CGATSParser.parse(cmyk) let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK") XCTAssertEqual(dataset.colorRep, "CMYK")
#expect(dataset.deviceClass == "PRINTER") XCTAssertEqual(dataset.deviceClass, "PRINTER")
} }
@Test("Rejects empty file") func testRejectsEmpty() {
func rejectsEmpty() { XCTAssertThrowsError(try CGATSParser.parse(""))
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
} }
@Test("Rejects malformed arity") func testRejectsArity() {
func rejectsArity() {
let bad = """ let bad = """
CTI3 CTI3
NUMBER_OF_FIELDS 2 NUMBER_OF_FIELDS 2
@@ -114,21 +104,18 @@ struct CGATSParserTests {
1 1
END_DATA END_DATA
""" """
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CGATSParser.parse(bad))
_ = try CGATSParser.parse(bad)
}
} }
@Test("Writer emits valid .ti3 with tabs and required keywords") func testWriterFormat() throws {
func writerFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3) let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset) let text = try CGATSWriter.write(dataset)
#expect(text.contains("CTI3")) XCTAssertTrue(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT")) XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA")) XCTAssertTrue(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA")) XCTAssertTrue(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP")) XCTAssertTrue(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS")) XCTAssertTrue(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t")) XCTAssertTrue(text.contains("\t"))
} }
} }
@@ -1,78 +1,67 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// Issue #83 canonical `CAL_` / original-stem pairing. /// Issue #83 canonical `CAL_` / original-stem pairing.
@Suite("CalibrationIdentity") final class CalibrationIdentityTests: XCTestCase {
struct CalibrationIdentityTests { func testLivePlain() {
@Test("live foo, no persisted")
func livePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
#expect(id.originalBasename == "foo") XCTAssertEqual(id.originalBasename, "foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("live foo ignores stale persisted") func testLivePlainIgnoresPersisted() {
func livePlainIgnoresPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar") let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
#expect(id.originalBasename == "foo") XCTAssertEqual(id.originalBasename, "foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("live CAL_foo, persisted foo") func testLiveCalPersisted() {
func liveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
#expect(id.originalBasename == "foo") XCTAssertEqual(id.originalBasename, "foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("live CAL_foo, empty persisted strips prefix") func testLiveCalNoPersist() {
func liveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "foo") XCTAssertEqual(id.originalBasename, "foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("persisted original wins over CAL_ live") func testPersistedWins() {
func persistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar") let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
#expect(id.originalBasename == "bar") XCTAssertEqual(id.originalBasename, "bar")
#expect(id.calibrationBasename == "CAL_bar") XCTAssertEqual(id.calibrationBasename, "CAL_bar")
} }
@Test("empty live yields empty identity even with persisted original") func testEmptyLiveWithPersisted() {
func emptyLiveWithPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo") let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
#expect(id.originalBasename.isEmpty) XCTAssertTrue(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty) XCTAssertTrue(id.calibrationBasename.isEmpty)
} }
@Test("empty live, empty persisted") func testEmptyLive() {
func emptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
#expect(id.originalBasename.isEmpty) XCTAssertTrue(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty) XCTAssertTrue(id.calibrationBasename.isEmpty)
} }
@Test("prefix is idempotent on already-prefixed input") func testAlreadyPrefixed() {
func alreadyPrefixed() { XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo") XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo")
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "") let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "CAL_foo") XCTAssertEqual(id.originalBasename, "CAL_foo")
#expect(id.calibrationBasename == "CAL_foo") XCTAssertEqual(id.calibrationBasename, "CAL_foo")
} }
@Test("prefix never invents a name from empty input") func testPrefixEmpty() {
func prefixEmpty() { XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
#expect(CalibrationIdentity.prefix("").isEmpty) XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
#expect(CalibrationIdentity.strip("foo") == "foo") XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
} }
@Test("runner process id for a calibration targen is targen_CAL_*") func testProcessIdMatches() {
func processIdMatches() {
let cal = CalibrationIdentity.prefix("foo") let cal = CalibrationIdentity.prefix("foo")
#expect(ProcessID.targen(cal) == "targen_CAL_foo") XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
} }
} }
@@ -1,12 +1,10 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CalibrationTargenArgs") final class CalibrationTargenArgsTests: XCTestCase {
struct CalibrationTargenArgsTests {
@Test("RGB baseline") func testRgbBaseline() throws {
func rgbBaseline() throws {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
steps: 21, steps: 21,
@@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp") workingDirectory: URL(fileURLWithPath: "/tmp")
) )
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"]) XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
} }
@Test("CMYK baseline with ink limit and neutral emphasis") func testCmykWithOptions() throws {
func cmykWithOptions() throws {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
steps: 25, steps: 25,
@@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp") workingDirectory: URL(fileURLWithPath: "/tmp")
) )
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"]) XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
} }
@Test("Rejects out-of-range steps") func testRejectsBadSteps() {
func rejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo") let config = CalibrationTargenConfig(steps: 5, basename: "demo")
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
_ = try CalibrationTargenArgs.build(config: config)
}
} }
@Test("Rejects bad CMYK ink limit") func testRejectsBadInkLimit() {
func rejectsBadInkLimit() {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
inkLimit: 500, inkLimit: 500,
basename: "demo" basename: "demo"
) )
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
_ = try CalibrationTargenArgs.build(config: config)
}
} }
@Test("Does not double-prefix an existing CAL_ basename") func testNoDoublePrefix() throws {
func noDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test") let config = CalibrationTargenConfig(basename: "CAL_test")
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
#expect(args.last == "CAL_test") XCTAssertEqual(args.last, "CAL_test")
} }
} }
+23 -32
View File
@@ -1,72 +1,63 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ColprofArgs") final class ColprofArgsTests: XCTestCase {
struct ColprofArgsTests {
@Test("Default algorithm and quality") func testDefaults() throws {
func defaults() throws {
let config = ColprofConfig(basename: "target") let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "target"]) XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"])
} }
@Test("FWA bare -f when empty string") func testFwaBareFlag() throws {
func fwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target") let config = ColprofConfig(fwa: "", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"]) XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"])
} }
@Test("FWA D50 and D65 emit -f value") func testFwaD50() throws {
func fwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target") let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(args.contains("-f")) XCTAssertTrue(args.contains("-f"))
#expect(args.contains("D50")) XCTAssertTrue(args.contains("D50"))
#expect(args.last == "target") XCTAssertEqual(args.last, "target")
} }
@Test("FWA none is omitted") func testFwaNoneOmitted() throws {
func fwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target") let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-f")) XCTAssertFalse(args.contains("-f"))
} }
@Test("Viewing conditions skip none") func testViewingCondNoneSkipped() throws {
func viewingCondNoneSkipped() throws {
let config = ColprofConfig( let config = ColprofConfig(
inputViewingCond: "none", inputViewingCond: "none",
outputViewingCond: "mt", outputViewingCond: "mt",
basename: "target" basename: "target"
) )
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-c")) XCTAssertFalse(args.contains("-c"))
#expect(args.contains("-d")) XCTAssertTrue(args.contains("-d"))
#expect(args.contains("mt")) XCTAssertTrue(args.contains("mt"))
} }
@Test("Description falls back to basename when empty") func testDescriptionFallback() throws {
func descriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target") let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-D")) XCTAssertFalse(args.contains("-D"))
} }
@Test("Copyright only when non-empty") func testCopyright() throws {
func copyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target") let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(args.contains("-C")) XCTAssertTrue(args.contains("-C"))
#expect(args.contains("Gronod 2026")) XCTAssertTrue(args.contains("Gronod 2026"))
} }
@Test("No -u passed") func testNoProgressJsonFlag() throws {
func noProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target") let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-u")) XCTAssertFalse(args.contains("-u"))
} }
} }
@@ -1,24 +1,20 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ColprofProgress") final class ColprofProgressTests: XCTestCase {
struct ColprofProgressTests {
@Test("Classifies gamut mapping") func testGamutMapping() {
func gamutMapping() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
} }
@Test("Classifies fitting or clut") func testFitting() {
func fitting() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut) XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
} }
@Test("Classifies writing") func testWriting() {
func writing() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc) XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
} }
} }
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
@@ -6,48 +6,42 @@ import AppKit
import ApplicationServices import ApplicationServices
/// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ). /// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ).
@Suite("CupsOptionsFilter") final class CupsOptionsFilterTests: XCTestCase {
struct CupsOptionsFilterTests {
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys") func testDropsReserved() {
func dropsReserved() {
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching " let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching " + "AP.ColorMatchingMode=AP_ApplicationColorMatching "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 " + "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
+ "collate=true copies=1 job-sheets=none,none " + "collate=true copies=1 job-sheets=none,none "
+ "pserrorhandler-requested=standard " + "pserrorhandler-requested=standard "
+ "MediaType=PhotographicGlossy" + "MediaType=PhotographicGlossy"
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy") XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
} }
@Test("Keeps relevant driver keys, order preserved") func testKeepsRelevant() {
func keepsRelevant() {
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 " let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
+ "Resolution=600x600dpi Duplex=None" + "Resolution=600x600dpi Duplex=None"
#expect(CupsOptionsFilter.filter(raw) == raw) XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
} }
@Test("Permissive: unknown non-com.* keys survive") func testKeepsUnknown() {
func keepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain" let raw = "VendorFooBar=baz MediaType=Plain"
#expect(CupsOptionsFilter.filter(raw) == raw) XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
} }
@Test("Drops empty keys and values") func testDropsEmpty() {
func dropsEmpty() {
let raw = "=noval MediaType= InputSlot=Rear" let raw = "=noval MediaType= InputSlot=Rear"
// "MediaType=" has an empty value dropped; "=noval" empty key. // "MediaType=" has an empty value dropped; "=noval" empty key.
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear") XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear")
} }
@Test("extractMediaType prefers MediaType then EPIJ_Medi") func testExtractMedia() {
func extractMedia() { XCTAssertEqual(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo") XCTAssertEqual(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "EPIJ_Medi=7"), "7")
fromOptionsString: "EPIJ_Medi=7") == "7") XCTAssertNil(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "PageSize=A4"))
fromOptionsString: "PageSize=A4") == nil)
} }
} }
@@ -55,9 +49,8 @@ struct CupsOptionsFilterTests {
/// `@convention(c)` closures can't capture, so recording goes through /// `@convention(c)` closures can't capture, so recording goes through
/// a file-scope recorder keyed by global state; no private symbols are /// a file-scope recorder keyed by global state; no private symbols are
/// touched. /// touched.
@Suite("ColorSyncSuppressor")
@MainActor @MainActor
struct ColorSyncSuppressorTests { final class ColorSyncSuppressorTests: XCTestCase {
/// Fake PMPrintSession the injected resolver never dereferences it. /// Fake PMPrintSession the injected resolver never dereferences it.
private var fakeSession: PMPrintSession { private var fakeSession: PMPrintSession {
@@ -90,55 +83,50 @@ struct ColorSyncSuppressorTests {
return s return s
} }
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first") func testAttemptOrder() {
func attemptOrder() {
Self.recorded = [] Self.recorded = []
Self.succeeding = nil Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"] Self.missing = ["PMSessionSetColorMatchingModeLock"]
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false) XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
// Lock is unresolvable skipped; the rest plays out in order. // Lock is unresolvable skipped; the rest plays out in order.
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
== ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" } .filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" }) .map { "\($0.symbol)|\($0.mode)" })
} }
@Test("First zero wins — later symbols/modes not called") func testFirstZeroWins() {
func firstZeroWins() {
Self.recorded = [] Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock", Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"AP_ApplicationColorMatching") "AP_ApplicationColorMatching")
Self.missing = [] Self.missing = []
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession)) XCTAssertTrue(s.applySPIMode(to: fakeSession))
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [ XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
]) ])
} }
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried") func testModeFallback() {
func modeFallback() {
Self.recorded = [] Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock", Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching") "ApplicationColorMatching")
Self.missing = [] Self.missing = []
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession)) XCTAssertTrue(s.applySPIMode(to: fakeSession))
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock") XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching") XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock") XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[1].1 == "ApplicationColorMatching") XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
#expect(Self.recorded.count == 2) XCTAssertEqual(Self.recorded.count, 2)
} }
@Test("All symbols missing → false, no calls") func testAllMissing() {
func allMissing() {
Self.recorded = [] Self.recorded = []
Self.succeeding = nil Self.succeeding = nil
Self.missing = Set(ColorMatchingAttempts.symbols) Self.missing = Set(ColorMatchingAttempts.symbols)
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false) XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
#expect(Self.recorded.isEmpty) XCTAssertTrue(Self.recorded.isEmpty)
} }
} }
+51 -62
View File
@@ -1,11 +1,10 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Issue 12 CUPS enumeration parsers on recorded fixtures /// Issue 12 CUPS enumeration parsers on recorded fixtures
/// (docs/1011). No live `lpstat`/`lpoptions` is spawned here. /// (docs/1011). No live `lpstat`/`lpoptions` is spawned here.
@Suite("CupsParsers") final class CupsParsersTests: XCTestCase {
struct CupsParsersTests {
// Recorded on an Epson XP-55 + Canon Pro9500 host. // Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """ private let lpstatE = """
@@ -34,112 +33,102 @@ struct CupsParsersTests {
cupsPrintQuality/cupsPrintQuality: Draft *Normal High cupsPrintQuality/cupsPrintQuality: Draft *Normal High
""" """
@Test("lpstat -e: one destination per line; empty = success") func testDestinations() {
func destinations() { XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
"Canon_Pro9500_II_series_XPS", "Canon_Pro9500_II_series_XPS",
"Epson_XP_55_LPD", "Epson_XP_55_LPD",
"EPSON_XP_55_Series", "EPSON_XP_55_Series",
]) ])
#expect(CupsParsers.lpstatDestinations("") == []) XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
} }
@Test("lpstat -p: idle / now-printing / disabled statuses") func testStatuses() {
func statuses() {
let s = CupsParsers.lpstatStatuses(lpstatP) let s = CupsParsers.lpstatStatuses(lpstatP)
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle) XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
#expect(s["Epson_XP_55_LPD"] == .printing) XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
#expect(s["EPSON_XP_55_Series"] == .stopped) XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
} }
@Test("lpstat -d: default destination or none") func testDefaultDestination() {
func defaultDestination() { XCTAssertEqual(CupsParsers.lpstatDefault(
#expect(CupsParsers.lpstatDefault( "system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
"system default destination: Canon_Pro9500_II_series_XPS\n") XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
} }
@Test("lpoptions -p: quoted printer-info, bare flags ignored") func testDisplayName() {
func displayName() { XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series") XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
} }
@Test("lpoptions -l: key/label split, * marks the default") func testOptionListings() {
func optionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL) let listings = CupsParsers.lpoptionsList(lpoptionsL)
#expect(listings.count == 6) XCTAssertEqual(listings.count, 6)
let page = listings[0] let page = listings[0]
#expect(page.key == "PageSize") XCTAssertEqual(page.key, "PageSize")
#expect(page.label == "Media Size") XCTAssertEqual(page.label, "Media Size")
#expect(page.defaultChoice == "A4") XCTAssertEqual(page.defaultChoice, "A4")
#expect(page.choices.contains("Custom.WIDTHxHEIGHT")) XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
#expect(!page.choices.contains("*A4")) XCTAssertFalse(page.choices.contains("*A4"))
let slot = listings[1] let slot = listings[1]
#expect(slot.key == "InputSlot") XCTAssertEqual(slot.key, "InputSlot")
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"]) XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
#expect(slot.defaultChoice == "Main") XCTAssertEqual(slot.defaultChoice, "Main")
} }
@Test("capabilities: trays/sizes index 1-based, media uses detected key") func testCapabilities() {
func capabilities() {
let service = CupsService() let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL) let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil) let caps = service.capabilities(from: listings, ppd: nil)
#expect(caps.trays == [ XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, name: "Auto"), PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"), PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"), PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"), PrinterTray(id: 4, name: "Rear"),
]) ])
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5")) XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
#expect(caps.paperSizes.count == 10) XCTAssertEqual(caps.paperSizes.count, 10)
#expect(caps.mediaTypes.map(\.id) == [ XCTAssertEqual(caps.mediaTypes.map(\.id), [
"Stationery", "PhotographicHighGloss", "Photographic", "Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope", "PhotographicMatte", "Envelope",
]) ])
#expect(caps.supportsOrientation) XCTAssertTrue(caps.supportsOrientation)
} }
@Test("PPD enrichment maps id → human label") func testPpdLabels() {
func ppdLabels() {
let ppd = """ let ppd = """
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>" *CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
*CNIJMediaType 0/Plain Paper: "" *CNIJMediaType 0/Plain Paper: ""
*en_US.CNIJMediaType 13/Envelope: "" *en_US.CNIJMediaType 13/Envelope: ""
""" """
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
#expect(labels["42"] == "Photo Paper Plus Semi-gloss") XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
#expect(labels["0"] == "Plain Paper") XCTAssertEqual(labels["0"], "Plain Paper")
#expect(labels["13"] == "Envelope") XCTAssertEqual(labels["13"], "Envelope")
} }
@Test("detectMediaTypeKey prefers vendor keys in order") func testMediaTypeKey() {
func mediaTypeKey() { XCTAssertEqual(CupsParsers.detectMediaTypeKey(
#expect(CupsParsers.detectMediaTypeKey( optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType") XCTAssertEqual(CupsParsers.detectMediaTypeKey(
#expect(CupsParsers.detectMediaTypeKey( optionKeys: ["PageSize", "MediaType"]), "MediaType")
optionKeys: ["PageSize", "MediaType"]) == "MediaType") XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
} }
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat") func testDriverBypass() {
func driverBypass() {
func pair(_ keys: Set<String>) -> String? { func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys) CupsParsers.detectDriverColorBypass(optionKeys: keys)
.map { "\($0.key)=\($0.value)" } .map { "\($0.key)=\($0.value)" }
} }
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4") XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4") XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0") XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3") XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected") XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected") XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off") XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
#expect(pair(["PageSize"]) == nil) XCTAssertNil(pair(["PageSize"]))
} }
} }
+20 -30
View File
@@ -1,65 +1,57 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("DriftAlert") final class DriftAlertTests: XCTestCase {
struct DriftAlertTests {
@Test("No alert with fewer than two poor results") func testNotEnough() {
func notEnough() {
let records = [ let records = [
record(avg: 4.0, at: 1000) record(avg: 4.0, at: 1000)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Alert on two poor results one hour apart") func testOneHourApart() {
func oneHourApart() {
let records = [ let records = [
record(avg: 4.0, at: 1000), record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 4600) record(avg: 5.0, at: 4600)
] ]
#expect(DriftAlert.compute(from: records) != nil) XCTAssertNotNil(DriftAlert.compute(from: records))
} }
@Test("No alert if same day and under one hour") func testSameDayUnderHour() {
func sameDayUnderHour() {
let records = [ let records = [
record(avg: 4.0, at: 1000), record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000) record(avg: 5.0, at: 2000)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Alert on distinct days") func testDistinctDays() {
func distinctDays() {
let day1 = record(avg: 4.0, at: 0) let day1 = record(avg: 4.0, at: 0)
let day2 = record(avg: 5.0, at: 86400 + 1000) let day2 = record(avg: 5.0, at: 86400 + 1000)
#expect(DriftAlert.compute(from: [day1, day2]) != nil) XCTAssertNotNil(DriftAlert.compute(from: [day1, day2]))
} }
@Test("Non-poor records do not trigger") func testNonPoor() {
func nonPoor() {
let records = [ let records = [
record(avg: 1.0, at: 0), record(avg: 1.0, at: 0),
record(avg: 1.5, at: 86400) record(avg: 1.5, at: 86400)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Non-poor records break the consecutive poor run") func testNonPoorBreaksRun() {
func nonPoorBreaksRun() {
let records = [ let records = [
record(avg: 4.0, at: 0), // poor record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good breaks the run record(avg: 1.0, at: 90000), // good breaks the run
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Only the final consecutive poor run is considered") func testOnlySuffixRun() {
func onlySuffixRun() {
let records = [ let records = [
record(avg: 4.0, at: 0), // poor record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 18000), // poor, > 1h from first record(avg: 4.5, at: 18000), // poor, > 1h from first
@@ -67,26 +59,24 @@ struct DriftAlertTests {
record(avg: 4.0, at: 25000), // poor record(avg: 4.0, at: 25000), // poor
record(avg: 4.5, at: 26000) // poor, < 1h and same day record(avg: 4.5, at: 26000) // poor, < 1h and same day
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Final consecutive poor run alerts when far apart") func testSuffixRunAlerts() {
func suffixRunAlerts() {
let records = [ let records = [
record(avg: 1.0, at: 0), // good record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor record(avg: 4.0, at: 1000), // poor
record(avg: 4.5, at: 4600) // poor, 1h after previous record(avg: 4.5, at: 4600) // poor, 1h after previous
] ]
#expect(DriftAlert.compute(from: records) != nil) XCTAssertNotNil(DriftAlert.compute(from: records))
} }
@Test("A single final poor record after good records does not alert") func testSingleFinalPoor() {
func singleFinalPoor() {
let records = [ let records = [
record(avg: 1.0, at: 0), record(avg: 1.0, at: 0),
record(avg: 4.0, at: 86400) record(avg: 4.0, at: 86400)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord { private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
+35 -38
View File
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -13,91 +13,88 @@ private func touch(_ url: URL, _ contents: String = "x") throws {
try contents.write(to: url, atomically: true, encoding: .utf8) try contents.write(to: url, atomically: true, encoding: .utf8)
} }
@Suite("PathSecurity") final class PathSecurityTests: XCTestCase {
struct PathSecurityTests { func testRejectsTraversalAndSeparators() {
@Test func rejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] { for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad)) XCTAssertFalse(PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) { XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
try PathSecurity.sanitizeBasename(bad) XCTAssertTrue(error is PathSecurity.Error)
} }
} }
} }
@Test func acceptsNormalNames() { func testAcceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] { for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
#expect(PathSecurity.isValidBasename(good)) XCTAssertTrue(PathSecurity.isValidBasename(good))
} }
} }
@Test func resolveSafeCwdPrefersExplicit() throws { func testResolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir() let dir = try tempDir()
#expect(PathSecurity.resolveSafeCwd(dir) == dir) XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
} }
@Test func resolveSafeCwdNeverReturnsNil() { func testResolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)") let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing) let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
} }
} }
@Suite("AtomicFileWriter") final class AtomicFileWriterTests: XCTestCase {
struct AtomicFileWriterTests { func testWritesAndLeavesNoTmp() throws {
@Test func writesAndLeavesNoTmp() throws {
let dir = try tempDir() let dir = try tempDir()
let url = dir.appendingPathComponent("state.json") let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url) try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}") XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path)) XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
} }
@Test func overwritesExistingAtomically() throws { func testOverwritesExistingAtomically() throws {
let dir = try tempDir() let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt") let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url) try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", to: url) try AtomicFileWriter.write("two-longer", to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer") XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer")
} }
@Test func createsParentDirs() throws { func testCreatesParentDirs() throws {
let dir = try tempDir() let dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json") let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url) try AtomicFileWriter.write("{}", to: url)
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
} }
} }
@Suite("ArtefactProbe") final class ArtefactProbeTests: XCTestCase {
struct ArtefactProbeTests { func testVerifyProgression() throws {
@Test func verifyProgression() throws {
let dir = try tempDir() let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir) var v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v == StageArtefacts()) XCTAssertEqual(v, StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1")) try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete) XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
try touch(dir.appendingPathComponent("t.ti2")) try touch(dir.appendingPathComponent("t.ti2"))
try touch(dir.appendingPathComponent("t.ti3")) try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete) XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
try touch(dir.appendingPathComponent("t.icc")) try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc") XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc")
} }
@Test func icmWinsOverIcc() throws { func testIcmWinsOverIcc() throws {
let dir = try tempDir() let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc")) try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm")) try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir) let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
#expect(profile?.pathExtension == "icm") XCTAssertEqual(profile?.pathExtension, "icm")
} }
@Test func enumeratesPassesPagesAndCAL() throws { func testEnumeratesPassesPagesAndCAL() throws {
let dir = try tempDir() let dir = try tempDir()
for name in [ for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif", "t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
@@ -115,15 +112,15 @@ struct ArtefactProbeTests {
"t.ti3", "t_pass1.ti3", "t_pass2.ti3", "t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal", "t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
] { ] {
#expect(names.contains(expected), "missing \(expected)") XCTAssertTrue(names.contains(expected), "missing \(expected)")
} }
#expect(!names.contains("other.ti1")) XCTAssertFalse(names.contains("other.ti1"))
#expect(!names.contains("t.txt")) XCTAssertFalse(names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1")) XCTAssertFalse(names.contains("CAL_other.ti1"))
} }
@Test func emptyDirReturnsEmpty() throws { func testEmptyDirReturnsEmpty() throws {
let dir = try tempDir() let dir = try tempDir()
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty) XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
} }
} }
@@ -1,15 +1,13 @@
import Testing import XCTest
import SceneKit import SceneKit
import ICCeryCore import ICCeryCore
@testable import ICCery @testable import ICCery
/// ``GamutSceneGeometryBuilder`` edge-case tests. /// ``GamutSceneGeometryBuilder`` edge-case tests.
@Suite("Gamut scene geometry builder")
@MainActor @MainActor
struct GamutGeometryBuilderTests { final class GamutGeometryBuilderTests: XCTestCase {
@Test("Drops out-of-bounds faces from the element without crashing") func testDropsOutOfBoundsFaces() {
func dropsOutOfBoundsFaces() {
let white = GamutVertex( let white = GamutVertex(
lab: LabColor(l: 100, a: 0, b: 0), lab: LabColor(l: 100, a: 0, b: 0),
rgb: DisplayRGB(r: 1, g: 1, b: 1) rgb: DisplayRGB(r: 1, g: 1, b: 1)
@@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests {
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh) let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer") XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer")
} }
} }
@@ -1,10 +1,9 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests. /// ``GamutMeshParser`` acceptance + edge-case tests.
@Suite("Gamut mesh parser") final class GamutMeshParserTests: XCTestCase {
struct GamutMeshParserTests {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL { private var bundledSRGBGamURL: URL {
@@ -13,16 +12,14 @@ struct GamutMeshParserTests {
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
} }
@Test("Parses bundled sRGB.gam") func testParsesBundledSRGB() throws {
func parsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL) let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices") XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces") XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
} }
@Test("Discards VERTEX_NO and uses push-order indices") func testDiscardsVertexNo() throws {
func discardsVertexNo() throws {
let text = """ let text = """
GAMUT GAMUT
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
@@ -49,14 +46,13 @@ struct GamutMeshParserTests {
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 4) XCTAssertEqual(mesh.vertices.count, 4)
#expect(mesh.faces.count == 2) XCTAssertEqual(mesh.faces.count, 2)
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30)) XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30))
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60)) XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60))
} }
@Test("Ignores comments and blank lines") func testIgnoresComments() throws {
func ignoresComments() throws {
let text = """ let text = """
# Header comment # Header comment
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
@@ -81,12 +77,11 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 2) XCTAssertEqual(mesh.vertices.count, 2)
#expect(mesh.faces.count == 1) XCTAssertEqual(mesh.faces.count, 1)
} }
@Test("Remaps coordinates to x=a*, y=L*, z=b*") func testRemapsCoordinates() throws {
func remapsCoordinates() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -99,11 +94,10 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80)) XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80))
} }
@Test("Computes per-vertex sRGB colour") func testComputesVertexColor() throws {
func computesVertexColor() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -116,14 +110,13 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
let white = try #require(mesh.vertices.first).rgb let white = try XCTUnwrap(mesh.vertices.first).rgb
#expect(white.r > 0.95) XCTAssertTrue(white.r > 0.95)
#expect(white.g > 0.95) XCTAssertTrue(white.g > 0.95)
#expect(white.b > 0.95) XCTAssertTrue(white.b > 0.95)
} }
@Test("Drops out-of-bounds face indices") func testDropsOutOfBoundsFaces() throws {
func dropsOutOfBoundsFaces() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -146,21 +139,23 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.faces.count == 1) XCTAssertEqual(mesh.faces.count, 1)
} }
@Test("Throws on empty file") func testThrowsOnEmptyFile() {
func throwsOnEmptyFile() { XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
#expect(throws: GamutMeshParseError.noDataBlock) { guard case GamutMeshParseError.noDataBlock = error else {
_ = try GamutMeshParser.parse(text: "") return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
}
} }
} }
@Test("Throws when file is missing") func testThrowsWhenMissing() {
func throwsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam") let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
#expect(throws: GamutMeshParseError.missingFile) { XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
_ = try GamutMeshParser.parse(url: url) guard case GamutMeshParseError.missingFile = error else {
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
}
} }
} }
} }
@@ -1,16 +1,14 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("IccgamutArgs") final class IccgamutArgsTests: XCTestCase {
struct IccgamutArgsTests {
@Test("Density is 10 and not a directory") func testDensityNotDirectory() throws {
func densityNotDirectory() throws {
let config = IccgamutConfig( let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc") profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
) )
let args = try IccgamutArgs.build(config: config) let args = try IccgamutArgs.build(config: config)
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"]) XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
} }
} }
+44 -57
View File
@@ -1,12 +1,11 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`). /// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`).
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover /// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
/// flag order, captured-option precedence, and sanitisation. /// flag order, captured-option precedence, and sanitisation.
@Suite("LpArgs") final class LpArgsTests: XCTestCase {
struct LpArgsTests {
private let tiff = "/tmp/work/target_001.tif" private let tiff = "/tmp/work/target_001.tif"
private let queue = "EPSON_XP_55_Series" private let queue = "EPSON_XP_55_Series"
@@ -20,112 +19,100 @@ struct LpArgsTests {
options: options, optionKeys: optionKeys) options: options, optionKeys: optionKeys)
} }
@Test("Header: -d queue -t title, both AP_* first, TIFF last") func testHeader() throws {
func header() throws {
let argv = try build() let argv = try build()
#expect(Array(argv[0...1]) == ["-d", queue]) XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"]) XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
#expect(Array(argv[4...5]) XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(Array(argv[6...7]) XCTAssertEqual(argv.last, tiff)
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
} }
@Test("Never emits -o raw; captured raw= is dropped") func testNeverRaw() throws {
func neverRaw() throws {
let argv = try build(options: PrintOptions( let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo")) cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" { for (i, arg) in argv.enumerated() where arg == "-o" {
#expect(argv[i + 1] != "raw") XCTAssertNotEqual(argv[i + 1], "raw")
#expect(argv[i + 1] != "raw=true") XCTAssertNotEqual(argv[i + 1], "raw=true")
} }
#expect(!argv.contains { $0.hasPrefix("raw=") }) XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
#expect(argv.contains("MediaType=Photo")) XCTAssertTrue(argv.contains("MediaType=Photo"))
} }
@Test("Captured options replayed after AP_* headers") func testCapturedReplay() throws {
func capturedReplay() throws {
let argv = try build(options: PrintOptions( let argv = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear MediaType=Photo")) cupsOptions: "InputSlot=Rear MediaType=Photo"))
let rear = argv.firstIndex(of: "InputSlot=Rear")! let rear = argv.firstIndex(of: "InputSlot=Rear")!
let apFirst = argv.firstIndex(of: let apFirst = argv.firstIndex(of:
"AP_ColorMatchingMode=AP_ApplicationColorMatching")! "AP_ColorMatchingMode=AP_ApplicationColorMatching")!
#expect(rear > apFirst) XCTAssertTrue(rear > apFirst)
} }
@Test("Captured wins: media key present → derived media skipped") func testCapturedWinsMedia() throws {
func capturedWinsMedia() throws {
let argv = try build( let argv = try build(
options: PrintOptions( options: PrintOptions(
mediaType: "Plain", mediaType: "Plain",
cupsOptions: "MediaType=Glossy"), cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"]) optionKeys: ["MediaType"])
#expect(argv.contains("MediaType=Glossy")) XCTAssertTrue(argv.contains("MediaType=Glossy"))
#expect(!argv.contains("MediaType=Plain")) XCTAssertFalse(argv.contains("MediaType=Plain"))
} }
@Test("Media emitted via detected key when not captured") func testMediaDerived() throws {
func mediaDerived() throws {
let argv = try build( let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"), options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"]) optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order. // CNIJMediaType wins over MediaType in detection order.
#expect(argv.contains("CNIJMediaType=SemiGloss")) XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
#expect(!argv.contains("MediaType=SemiGloss")) XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
} }
@Test("Driver bypass emitted when absent, skipped when captured") func testBypassRules() throws {
func bypassRules() throws {
let withBypass = try build( let withBypass = try build(
optionKeys: ["EPIJ_CMat"]) optionKeys: ["EPIJ_CMat"])
#expect(withBypass.contains("EPIJ_CMat=3")) XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
let captured = try build( let captured = try build(
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
optionKeys: ["EPIJ_CMat"]) optionKeys: ["EPIJ_CMat"])
// Captured value kept, detection not re-applied. // Captured value kept, detection not re-applied.
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") } XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
== ["EPIJ_CMat=1"])
} }
@Test("Orientation: portrait=3 landscape=4; captured wins") func testOrientation() throws {
func orientation() throws { XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
#expect(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3")) .contains("orientation-requested=3"))
#expect(try build(options: PrintOptions(orientation: "landscape")) XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4")) .contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions( let capturedOrients = try build(options: PrintOptions(
orientation: "landscape", orientation: "landscape",
cupsOptions: "orientation-requested=5")) cupsOptions: "orientation-requested=5"))
#expect(!capturedOrients.contains("orientation-requested=4")) XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
#expect(capturedOrients.contains("orientation-requested=5")) XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
} }
@Test("PageSize emitted unless captured") func testPageSize() throws {
func pageSize() throws { XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
#expect(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4")) .contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions( let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter")) paperSize: "A4", cupsOptions: "PageSize=Letter"))
#expect(!capturedSize.contains("PageSize=A4")) XCTAssertFalse(capturedSize.contains("PageSize=A4"))
#expect(capturedSize.contains("PageSize=Letter")) XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
} }
@Test("Sanitise rejects `;`, newline, and shell metachars") func testSanitise() throws {
func sanitise() throws { XCTAssertThrowsError(try build(options: PrintOptions(
#expect(throws: LpArgsError.self) { cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
_ = try build(options: PrintOptions( XCTAssertTrue(error is LpArgsError)
cupsOptions: "InputSlot=Rear;rm -rf /"))
} }
#expect(throws: LpArgsError.self) { XCTAssertThrowsError(try build(options: PrintOptions(
_ = try build(options: PrintOptions( cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
cupsOptions: "InputSlot=Rear\nMediaType=Photo")) XCTAssertTrue(error is LpArgsError)
} }
#expect(throws: LpArgsError.self) { XCTAssertThrowsError(try build(options: PrintOptions(
_ = try build(options: PrintOptions( cupsOptions: "InputSlot=$(whoami)"))) { error in
cupsOptions: "InputSlot=$(whoami)")) XCTAssertTrue(error is LpArgsError)
} }
} }
} }
+93 -128
View File
@@ -1,12 +1,10 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("InstrumentParser") final class InstrumentParserTests: XCTestCase {
struct InstrumentParserTests {
@Test("Parses pretty-printed instlist JSON") func testJson() throws {
func json() throws {
let json = """ let json = """
{ {
"event": "instruments", "event": "instruments",
@@ -18,134 +16,118 @@ struct InstrumentParserTests {
} }
""" """
let devices = try InstrumentParser.parse(json) let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3) XCTAssertEqual(devices.count, 3)
#expect(devices[0].port == 1) XCTAssertEqual(devices[0].port, 1)
#expect(devices[0].name == "X-Rite i1Pro") XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
#expect(devices[2].port == 3) XCTAssertEqual(devices[2].port, 3)
} }
@Test("Falls back to regex for legacy instlist text") func testRegexFallback() throws {
func regexFallback() throws {
let text = """ let text = """
1: 'X-Rite i1Pro' on usb 1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile' 2: 'ColorMunki Smile'
""" + "\n" """ + "\n"
let devices = try InstrumentParser.parse(text) let devices = try InstrumentParser.parse(text)
#expect(devices.count == 2) XCTAssertEqual(devices.count, 2)
#expect(devices[0].port == 1) XCTAssertEqual(devices[0].port, 1)
#expect(devices[1].name == "ColorMunki Smile") XCTAssertEqual(devices[1].name, "ColorMunki Smile")
} }
@Test("Empty output returns no devices") func testEmpty() throws {
func empty() throws { XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
#expect(try InstrumentParser.parse("").isEmpty)
} }
} }
@Suite("ChartreadArgs") final class ChartreadArgsTests: XCTestCase {
struct ChartreadArgsTests {
@Test("Baseline argv and port 1 omits -c") func testBaseline() throws {
func baseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1) let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "target"]) XCTAssertEqual(args, ["-v", "-u", "target"])
} }
@Test("Port > 1 emits -c") func testPortArgument() throws {
func portArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3) let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "-c", "3", "target"]) XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"])
} }
@Test("LEDs emit -Y l") func testLeds() throws {
func leds() throws {
let config = ChartreadConfig( let config = ChartreadConfig(
basename: "target", basename: "target",
selectedPort: 2, selectedPort: 2,
enableLEDs: true enableLEDs: true
) )
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
#expect(args.contains("-Y")) XCTAssertTrue(args.contains("-Y"))
#expect(args.contains("l")) XCTAssertTrue(args.contains("l"))
} }
@Test("Auto omits -c") func testAutoPort() throws {
func autoPort() throws {
let config = ChartreadConfig(basename: "target") let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
#expect(!args.contains("-c")) XCTAssertFalse(args.contains("-c"))
} }
} }
@Suite("ChartreadClassifier") final class ChartreadClassifierTests: XCTestCase {
struct ChartreadClassifierTests {
@Test("Calibration prompt") func testCalibration() {
func calibration() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.", line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle previousState: .idle
) )
#expect(r.state == .calibrating) XCTAssertEqual(r.state, .calibrating)
} }
@Test("Strip awaiting") func testAwaitingStrip() {
func awaitingStrip() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A", line: "Hit [Space] to read strip A",
previousState: .calibrating previousState: .calibrating
) )
#expect(r.state == .awaitingStrip) XCTAssertEqual(r.state, .awaitingStrip)
} }
@Test("Done prompt") func testDone() {
func done() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "'d' if/when done", line: "'d' if/when done",
previousState: .awaitingStrip previousState: .awaitingStrip
) )
#expect(r.state == .allStripsRead) XCTAssertEqual(r.state, .allStripsRead)
} }
@Test("XY place sheet") func testPlaceSheet() {
func placeSheet() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table", line: "Please place sheet 1 of 2 on the table",
previousState: .idle previousState: .idle
) )
#expect(r.state == .tablePlaceSheet) XCTAssertEqual(r.state, .tablePlaceSheet)
#expect(r.sheetNumber == 1) XCTAssertEqual(r.sheetNumber, 1)
#expect(r.sheetTotal == 2) XCTAssertEqual(r.sheetTotal, 2)
} }
@Test("XY locate patch") func testLocatePatch() {
func locatePatch() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,", line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet previousState: .tablePlaceSheet
) )
#expect(r.state == .tableAlign) XCTAssertEqual(r.state, .tableAlign)
#expect(r.alignmentPatch == "A1") XCTAssertEqual(r.alignmentPatch, "A1")
} }
@Test("Remove sheet notice preserves state") func testRemoveNotice() {
func removeNotice() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table", line: "Please remove last sheet from table",
previousState: .tablePlaceSheet previousState: .tablePlaceSheet
) )
#expect(r.state == .tablePlaceSheet) XCTAssertEqual(r.state, .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true) XCTAssertEqual(r.isRemoveSheetNotice, true)
} }
} }
@Suite("ChartreadRow") final class ChartreadRowTests: XCTestCase {
struct ChartreadRowTests {
@Test("Decodes row JSON") func testDecode() throws {
func decode() throws {
let json = """ let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [ "patch_count": 1, "patches": [
@@ -155,13 +137,12 @@ struct ChartreadRowTests {
]} ]}
""" """
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A") XCTAssertEqual(row.rowId, "A")
#expect(row.patchCount == 1) XCTAssertEqual(row.patchCount, 1)
#expect(row.patches[0].measured.lab?.l == 51) XCTAssertEqual(row.patches[0].measured.lab?.l, 51)
} }
@Test("Decodes a row carrying both XYZ and Lab arrays") func testDecodeXYZAndLab() throws {
func decodeXYZAndLab() throws {
let json = """ let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [ "patch_count": 1, "patches": [
@@ -171,88 +152,78 @@ struct ChartreadRowTests {
""" """
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured let measured = row.patches[0].measured
#expect(measured.xyz == CIEXYZ(x: 30.5, y: 32.1, z: 25.9)) XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
#expect(measured.lab == CIELab(l: 63.4, a: 2.5, b: -8.2)) XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
} }
@Test("XYZColor/CIEXYZ encode as an unkeyed three-number array") func testXyzWireEncoding() throws {
func xyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] { for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
let value = try JSONSerialization.jsonObject( let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) with: JSONEncoder().encode(color))
#expect(value as? [Double] == [1.5, 2.5, 3.5]) XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5])
} }
} }
@Test("LabColor/CIELab encode as an unkeyed three-number array") func testLabWireEncoding() throws {
func labWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] { for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject( let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) with: JSONEncoder().encode(color))
#expect(value as? [Double] == [50, -1, 2]) XCTAssertEqual(value as? [Double], [50, -1, 2])
} }
} }
@Test("PatchColor keeps the XYZ and Lab keys over unkeyed arrays") func testPatchColorKeys() throws {
func patchColorKeys() throws {
let color = PatchColor( let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30), xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2)) lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject( let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any] with: JSONEncoder().encode(color)) as? [String: Any]
#expect(object?["XYZ"] as? [Double] == [10, 20, 30]) XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
#expect(object?["Lab"] as? [Double] == [55, 1, -2]) XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
#expect(object?["spectral"] == nil) XCTAssertNil(object?["spectral"])
} }
} }
@Suite("ColourMath") final class ColourMathTests: XCTestCase {
struct ColourMathTests {
@Test("White XYZ to Lab") func testWhiteLab() {
func whiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188) let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white) let lab = LabColorMath.xyzToLab(white)
#expect(abs(lab.l - 100) < 0.5) XCTAssertTrue(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5) XCTAssertTrue(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5) XCTAssertTrue(abs(lab.b) < 0.5)
} }
@Test("Lab to sRGB roundtrip is clamped") func testLabToSRGB() {
func labToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70) let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red) let rgb = LabColorMath.labToSRGB(red)
#expect(rgb.r > 0.8) XCTAssertTrue(rgb.r > 0.8)
#expect(rgb.g < 0.2) XCTAssertTrue(rgb.g < 0.2)
#expect(rgb.b < 0.2) XCTAssertTrue(rgb.b < 0.2)
} }
@Test("Pad white returns DisplayRGB") func testPadWhite() {
func padWhite() {
let white = LabColor(l: 95, a: 0, b: 0) let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white) let rgb = LabColorMath.labToSRGB(white)
#expect(rgb.r > 0.9) XCTAssertTrue(rgb.r > 0.9)
#expect(rgb.g > 0.9) XCTAssertTrue(rgb.g > 0.9)
#expect(rgb.b > 0.9) XCTAssertTrue(rgb.b > 0.9)
} }
@Test("Standard CIEDE2000 vector (Sharma)") func testCiede2000() {
func ciede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814) let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485) let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001) XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
} }
@Test("Classification respects thresholds") func testClassify() {
func classify() { XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good)
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good) XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning) XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
} }
} }
@Suite("MeasurementArtefacts") final class MeasurementArtefactTests: XCTestCase {
struct MeasurementArtefactTests {
private func makeCwd() throws -> URL { private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
@@ -261,8 +232,7 @@ struct MeasurementArtefactTests {
return url return url
} }
@Test("Discovers passes in order") func testDiscovery() throws {
func discovery() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } defer { try? FileManager.default.removeItem(at: cwd) }
@@ -271,11 +241,10 @@ struct MeasurementArtefactTests {
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8) try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd) let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"]) XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
} }
@Test("Snapshot and promote are atomic") func testSnapshotPromote() throws {
func snapshotPromote() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } defer { try? FileManager.default.removeItem(at: cwd) }
@@ -283,16 +252,15 @@ struct MeasurementArtefactTests {
try "canonical".write(to: canonical, atomically: true, encoding: .utf8) try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass.lastPathComponent == "target_pass1.ti3") XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd) let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
#expect(promoted.lastPathComponent == "target.ti3") XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
} }
@Test("Pass collisions handled") func testCollision() throws {
func collision() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } defer { try? FileManager.default.removeItem(at: cwd) }
@@ -302,28 +270,25 @@ struct MeasurementArtefactTests {
try "v2".write(to: canonical, atomically: true, encoding: .utf8) try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass2.lastPathComponent == "target_pass2.ti3") XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
} }
} }
@Suite("AverageArgs") final class AverageArgsTests: XCTestCase {
struct AverageArgsTests {
@Test("Requires at least two pass files") func testPassCount() {
func passCount() {
let cwd = URL(fileURLWithPath: "/tmp") let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
basename: "target", basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")] passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
) )
#expect(throws: AverageArgError.self) { XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
_ = try AverageArgs.build(config: config) XCTAssertTrue(error is AverageArgError)
} }
} }
@Test("Output is last and inputs are relative") func testOrdering() throws {
func ordering() throws {
let cwd = URL(fileURLWithPath: "/tmp") let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
@@ -334,8 +299,8 @@ struct AverageArgsTests {
] ]
) )
let args = try AverageArgs.build(config: config) let args = try AverageArgs.build(config: config)
#expect(args.first == "-v") XCTAssertEqual(args.first, "-v")
#expect(args.last == "target.ti3") XCTAssertEqual(args.last, "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"]) XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
} }
} }
+13 -21
View File
@@ -1,24 +1,21 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("PrintcalArgs") final class PrintcalArgsTests: XCTestCase {
struct PrintcalArgsTests {
private let tmp = URL(fileURLWithPath: "/tmp/out.cal") private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
@Test("Default printcal argv") func testDefaults() throws {
func defaults() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "CAL_demo", ti3Basename: "CAL_demo",
outputURL: tmp outputURL: tmp
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
} }
@Test("All options and channel limits") func testAllOptions() throws {
func allOptions() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
@@ -32,7 +29,7 @@ struct PrintcalArgsTests {
] ]
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
#expect(args == [ XCTAssertEqual(args, [
"-v", "-e", "-v", "-e",
"-I", "-z", "-I", "-z",
"-a", "/tmp/old.cal", "-a", "/tmp/old.cal",
@@ -44,39 +41,34 @@ struct PrintcalArgsTests {
]) ])
} }
@Test("Whitespace-only previous calibration path emits no -a") func testWhitespacePreviousCal() throws {
func whitespacePreviousCal() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
previousCalPath: " \n\t " previousCalPath: " \n\t "
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
#expect(!args.contains("-a")) XCTAssertFalse(args.contains("-a"))
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
} }
@Test("Previous calibration path is trimmed before emission") func testPreviousCalTrimmed() throws {
func previousCalTrimmed() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
previousCalPath: " /tmp/old.cal " previousCalPath: " /tmp/old.cal "
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
#expect(args[args.firstIndex(of: "-a")! + 1] == "/tmp/old.cal") XCTAssertEqual(args[args.firstIndex(of: "-a")! + 1], "/tmp/old.cal")
} }
@Test("Rejects invalid per-channel limit") func testRejectsBadChannelLimit() {
func rejectsBadChannelLimit() {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)] channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
) )
#expect(throws: (any Error).self) { XCTAssertThrowsError(try PrintcalArgs.build(config: config))
_ = try PrintcalArgs.build(config: config)
}
} }
} }
+81 -107
View File
@@ -1,9 +1,9 @@
import Testing import Testing
import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("PrinttargArgs") final class PrinttargArgsTests: XCTestCase {
struct PrinttargArgsTests {
private func config( private func config(
instrument: PrintInstrument = .i1, instrument: PrintInstrument = .i1,
@@ -28,136 +28,121 @@ struct PrinttargArgsTests {
) )
} }
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300") func testBaseline() throws {
func baseline() throws {
let args = try PrinttargArgs.build(config: config()) let args = try PrinttargArgs.build(config: config())
#expect(args == ["-v", "-u", "-i", "i1", "-p", "A4", XCTAssertEqual(args, ["-v", "-u", "-i", "i1", "-p", "A4",
"-R", "1", "-t", "300", "target"]) "-R", "1", "-t", "300", "target"])
} }
@Test("Default layout is deterministic -R 1, never bare") func testDeterministicDefault() throws {
func deterministicDefault() throws {
let args = try PrinttargArgs.build(config: config()) let args = try PrinttargArgs.build(config: config())
#expect(args.contains("-R")) XCTAssertTrue(args.contains("-R"))
#expect(!args.contains("-r")) XCTAssertFalse(args.contains("-r"))
#expect(args[args.firstIndex(of: "-R")! + 1] == "1") XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1")
} }
@Test("Custom seed -R N; seed < 1 throws") func testCustomSeed() throws {
func customSeed() throws {
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42)) let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
#expect(args[args.firstIndex(of: "-R")! + 1] == "42") XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42")
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)") func testRaster() throws {
func raster() throws {
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9)) let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
#expect(args.contains("-r")) XCTAssertTrue(args.contains("-r"))
#expect(!args.contains("-R")) XCTAssertFalse(args.contains("-R"))
} }
@Test("Label: -d emits the resolved string, not a colour space") func testLabel() throws {
func label() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04")) config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
let i = args.firstIndex(of: "-d")! let i = args.firstIndex(of: "-d")!
#expect(args[i + 1].hasPrefix("ICCery - t")) XCTAssertTrue(args[i + 1].hasPrefix("ICCery - t"))
} }
@Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600") func testBitDepthAndDPI() throws {
func bitDepthAndDPI() throws { XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
.contains("-T")) .contains("-T"))
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72)) XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
.contains("-t")) .contains("-t"))
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in
try PrinttargArgs.build(config: config(dpi: 71)) XCTAssertTrue(error is PrinttargArgError)
} }
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in
try PrinttargArgs.build(config: config(dpi: 601)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("All instruments emit their Argyll code") func testInstruments() throws {
func instruments() throws {
let expected: [(PrintInstrument, String)] = [ let expected: [(PrintInstrument, String)] = [
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"), (.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"), (.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
] ]
for (inst, code) in expected { for (inst, code) in expected {
let args = try PrinttargArgs.build(config: config(instrument: inst)) let args = try PrinttargArgs.build(config: config(instrument: inst))
#expect(args[args.firstIndex(of: "-i")! + 1] == code) XCTAssertEqual(args[args.firstIndex(of: "-i")! + 1], code)
} }
} }
@Test("All fixed page sizes; custom emits WxH in mm") func testPageSizes() throws {
func pageSizes() throws {
for size in PageSize.allCases where size != .custom { for size in PageSize.allCases where size != .custom {
let args = try PrinttargArgs.build(config: config(pageSize: size)) let args = try PrinttargArgs.build(config: config(pageSize: size))
#expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue) XCTAssertEqual(args[args.firstIndex(of: "-p")! + 1], size.rawValue)
} }
let custom = try PrinttargArgs.build(config: config( let custom = try PrinttargArgs.build(config: config(
pageSize: .custom, customW: 150, customH: 220)) pageSize: .custom, customW: 150, customH: 220))
#expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220") XCTAssertEqual(custom[custom.firstIndex(of: "-p")! + 1], "150x220")
} }
@Test("Custom page below 50 mm throws") func testCustomPageTooSmall() {
func customPageTooSmall() { XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in
#expect(throws: PrinttargArgError.self) { XCTAssertTrue(error is PrinttargArgError)
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
} }
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("Calibration: -K applies, -I embeds") func testCalibrationFlags() throws {
func calibrationFlags() throws {
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal")) let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal"))
#expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal") XCTAssertEqual(k[k.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true)) let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal") XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal")
#expect(!i.contains("-K")) XCTAssertFalse(i.contains("-K"))
} }
@Test("CAL_ basename never gets -K or -I") func testCalProtection() throws {
func calProtection() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(calFile: "/tmp/a.cal", basename: "CAL_test")) config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
#expect(!args.contains("-K")) XCTAssertFalse(args.contains("-K"))
#expect(!args.contains("-I")) XCTAssertFalse(args.contains("-I"))
} }
@Test("Whitespace-only label emits no -d; whitespace-only calibration emits no -K/-I") func testWhitespaceOptions() throws {
func whitespaceOptions() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(label: " \n ", calFile: " \t ")) config: config(label: " \n ", calFile: " \t "))
#expect(!args.contains("-d")) XCTAssertFalse(args.contains("-d"))
#expect(!args.contains("-K")) XCTAssertFalse(args.contains("-K"))
#expect(!args.contains("-I")) XCTAssertFalse(args.contains("-I"))
} }
@Test("Label and calibration values are trimmed before emission") func testTrimmedOptions() throws {
func trimmedOptions() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(label: " My Label ", calFile: " /tmp/a.cal ")) config: config(label: " My Label ", calFile: " /tmp/a.cal "))
#expect(args[args.firstIndex(of: "-d")! + 1] == "My Label") XCTAssertEqual(args[args.firstIndex(of: "-d")! + 1], "My Label")
#expect(args[args.firstIndex(of: "-K")! + 1] == "/tmp/a.cal") XCTAssertEqual(args[args.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
} }
@Test("Unsafe basename throws") func testUnsafeBasename() {
func unsafeBasename() { XCTAssertThrowsError(try PrinttargArgs.build(config: config(basename: "../x"))) { error in
#expect(throws: PathSecurity.Error.self) { XCTAssertTrue(error is PathSecurity.Error)
try PrinttargArgs.build(config: config(basename: "../x"))
} }
} }
} }
@Suite("PrinttargLabel") final class PrinttargLabelTests: XCTestCase {
struct PrinttargLabelTests {
private var fixedDate: Date { private var fixedDate: Date {
var comps = DateComponents() var comps = DateComponents()
@@ -166,37 +151,33 @@ struct PrinttargLabelTests {
return Calendar(identifier: .gregorian).date(from: comps)! return Calendar(identifier: .gregorian).date(from: comps)!
} }
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM") func testAutomatic() {
func automatic() {
let label = PrinttargLabel.automatic( let label = PrinttargLabel.automatic(
basename: "tgt", basename: "tgt",
metadata: TargetLabelMetadata( metadata: TargetLabelMetadata(
printer: "Epson", inkSet: "CMYK", printer: "Epson", inkSet: "CMYK",
driverPaper: "Photo", actualPaper: "Matte"), driverPaper: "Photo", actualPaper: "Matte"),
date: fixedDate, timeZone: .current) date: fixedDate, timeZone: .current)
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - ")) XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026")) XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
} }
@Test("Missing metadata becomes Unspecified") func testUnspecified() {
func unspecified() {
let label = PrinttargLabel.automatic( let label = PrinttargLabel.automatic(
basename: "tgt", metadata: TargetLabelMetadata(), basename: "tgt", metadata: TargetLabelMetadata(),
date: fixedDate, timeZone: .current) date: fixedDate, timeZone: .current)
#expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - ")) XCTAssertTrue(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
} }
@Test("Manual label wins over automatic") func testManualWins() {
func manualWins() {
let resolved = PrinttargLabel.resolved( let resolved = PrinttargLabel.resolved(
customLabel: " My Label ", basename: "tgt", customLabel: " My Label ", basename: "tgt",
metadata: TargetLabelMetadata(), date: fixedDate) metadata: TargetLabelMetadata(), date: fixedDate)
#expect(resolved == "My Label") XCTAssertEqual(resolved, "My Label")
} }
} }
@Suite("PrinttargManifest") final class PrinttargManifestTests: XCTestCase {
struct PrinttargManifestTests {
private let prettySingle = """ private let prettySingle = """
Some log line Some log line
@@ -225,59 +206,52 @@ struct PrinttargManifestTests {
} }
""" """
@Test("Decodes a single-page pretty manifest amid log noise") func testSinglePage() throws {
func singlePage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettySingle) let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
#expect(m.event == "manifest") XCTAssertEqual(m.event, "manifest")
#expect(m.pages.count == 1) XCTAssertEqual(m.pages.count, 1)
#expect(m.pages[0].filename == "target.tif") XCTAssertEqual(m.pages[0].filename, "target.tif")
#expect(m.pages[0].patches == 800) XCTAssertEqual(m.pages[0].patches, 800)
} }
@Test("Multi-page manifest preserves order") func testMultiPage() throws {
func multiPage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti) let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
#expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"]) XCTAssertEqual(m.pages.map(\.filename), ["p1.tif", "p2.tif"])
} }
@Test("No JSON document → noJSONDocument") func testNoJSON() {
func noJSON() { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in
#expect(throws: ManifestError.self) { XCTAssertTrue(error is ManifestError)
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
} }
} }
@Test("Wrong event → wrongEvent") func testWrongEvent() {
func wrongEvent() {
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n" let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
#expect(throws: ManifestError.self) { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
try PrinttargManifestExtractor.manifest(from: stdout) XCTAssertTrue(error is ManifestError)
} }
} }
@Test("ROW_COLORS_JSON line is never treated as the manifest") func testRowColorsNotManifest() {
func rowColorsNotManifest() {
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}" let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
// Extraction only starts at a '{' that begins a trimmed line, // Extraction only starts at a '{' that begins a trimmed line,
// so the ROW_COLORS_JSON line is skipped entirely. // so the ROW_COLORS_JSON line is skipped entirely.
let m = try? PrinttargManifestExtractor.manifest(from: stdout) let m = try? PrinttargManifestExtractor.manifest(from: stdout)
#expect(m != nil) XCTAssertNotNil(m)
#expect(m?.event == "manifest") XCTAssertEqual(m?.event, "manifest")
} }
@Test("Braces inside a quoted filename do not corrupt the scan") func testBracesInFilename() throws {
func bracesInFilename() throws {
let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n" let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n"
let m = try PrinttargManifestExtractor.manifest(from: stdout) let m = try PrinttargManifestExtractor.manifest(from: stdout)
#expect(m.pages[0].filename == "a}b.tif") XCTAssertEqual(m.pages[0].filename, "a}b.tif")
} }
@Test("Unsafe / non-TIFF filenames rejected") func testUnsafeFilenames() {
func unsafeFilenames() {
for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] { for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] {
let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}" let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}"
#expect(throws: ManifestError.self) { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
try PrinttargManifestExtractor.manifest(from: stdout) XCTAssertTrue(error is ManifestError)
} }
} }
} }
+30 -32
View File
@@ -1,4 +1,5 @@
import Testing import Testing
import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -408,65 +409,62 @@ struct ProcessManagerTests {
} }
} }
@Suite("ProcessLineDecoder") final class ProcessLineDecoderTests: XCTestCase {
struct ProcessLineDecoderTests { func testSplitsAcrossChunkBoundaries() {
@Test func splitsAcrossChunkBoundaries() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
#expect(d.feed(Data("he".utf8)) == []) XCTAssertEqual(d.feed(Data("he".utf8)), [])
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"]) XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"])
#expect(d.feed(Data("ld\n".utf8)) == ["world"]) XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"])
#expect(d.finish() == nil) XCTAssertNil(d.finish())
} }
@Test func crlfIsStripped() { func testCrlfIsStripped() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"]) XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"])
} }
@Test func finishReturnsRemainder() { func testFinishReturnsRemainder() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
_ = d.feed(Data("x".utf8)) _ = d.feed(Data("x".utf8))
#expect(d.finish() == "x") XCTAssertEqual(d.finish(), "x")
#expect(d.finish() == nil) XCTAssertNil(d.finish())
} }
} }
@Suite("JSONAccumulator") final class JSONAccumulatorTests: XCTestCase {
struct JSONAccumulatorTests { func testMultilinePrettyJSON() {
@Test func multilinePrettyJSON() {
var acc = JSONAccumulator() var acc = JSONAccumulator()
#expect(acc.feed(line: "{") == nil) XCTAssertNil(acc.feed(line: "{"))
#expect(acc.feed(line: " \"k\": 1") == nil) XCTAssertNil(acc.feed(line: " \"k\": 1"))
let done = acc.feed(line: "}") let done = acc.feed(line: "}")
#expect(done != nil) XCTAssertNotNil(done)
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int] let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
#expect(obj?["k"] == 1) XCTAssertEqual(obj?["k"], 1)
} }
@Test func nonJSONLinesIgnored() { func testNonJSONLinesIgnored() {
var acc = JSONAccumulator() var acc = JSONAccumulator()
#expect(acc.feed(line: "Reading instrument...") == nil) XCTAssertNil(acc.feed(line: "Reading instrument..."))
#expect(acc.feed(line: "still text") == nil) XCTAssertNil(acc.feed(line: "still text"))
#expect(acc.completeData == nil) XCTAssertNil(acc.completeData)
} }
@Test func decodeTyped() { func testDecodeTyped() {
struct Doc: Decodable { let n: Int } struct Doc: Decodable { let n: Int }
var acc = JSONAccumulator() var acc = JSONAccumulator()
// Split so the doc completes on the second feed. // Split so the doc completes on the second feed.
#expect(acc.feed(line: "{\"n\":") == nil) XCTAssertNil(acc.feed(line: "{\"n\":"))
let data = acc.feed(line: "7}") let data = acc.feed(line: "7}")
#expect(data != nil) XCTAssertNotNil(data)
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) } let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
#expect(doc?.n == 7) XCTAssertEqual(doc?.n, 7)
#expect(acc.isEmpty) XCTAssertTrue(acc.isEmpty)
} }
} }
@Suite("LogSanitizer") final class LogSanitizerTests: XCTestCase {
struct LogSanitizerTests { func testHomeIsRewritten() {
@Test func homeIsRewritten() {
let path = "\(NSHomeDirectory())/Documents/foo.ti1" let path = "\(NSHomeDirectory())/Documents/foo.ti1"
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1") XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1")
} }
} }
@@ -1,17 +1,15 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfcheckArgs") final class ProfcheckArgsTests: XCTestCase {
struct ProfcheckArgsTests {
@Test("Hard-coded argv") func testArgv() throws {
func argv() throws {
let config = ProfcheckConfig( let config = ProfcheckConfig(
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"), ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
iccURL: URL(fileURLWithPath: "/tmp/target.icc") iccURL: URL(fileURLWithPath: "/tmp/target.icc")
) )
let args = try ProfcheckArgs.build(config: config) let args = try ProfcheckArgs.build(config: config)
#expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"]) XCTAssertEqual(args, ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
} }
} }
@@ -1,43 +1,39 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfcheckParser") final class ProfcheckParserTests: XCTestCase {
struct ProfcheckParserTests {
@Test("Prefers JSON report with de2000 keys") func testJsonReport() {
func jsonReport() {
let output = """ let output = """
No of test patches = 52 No of test patches = 52
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02} {"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99 Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.patchCount == 52) XCTAssertEqual(report.patchCount, 52)
#expect(report.avgDE == 0.85) XCTAssertEqual(report.avgDE, 0.85)
#expect(report.maxDE == 2.41) XCTAssertEqual(report.maxDE, 2.41)
#expect(report.rmsDE == 1.02) XCTAssertEqual(report.rmsDE, 1.02)
#expect(report.status == .excellent) XCTAssertEqual(report.status, .excellent)
} }
@Test("Falls back to legacy text") func testLegacyText() {
func legacyText() {
let output = """ let output = """
No of test patches = 120 No of test patches = 120
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95 Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.patchCount == 120) XCTAssertEqual(report.patchCount, 120)
#expect(report.avgDE == 1.80) XCTAssertEqual(report.avgDE, 1.80)
#expect(report.maxDE == 3.50) XCTAssertEqual(report.maxDE, 3.50)
#expect(report.rmsDE == 0.95) XCTAssertEqual(report.rmsDE, 0.95)
#expect(report.status == .good) XCTAssertEqual(report.status, .good)
} }
@Test("Broad regex fallback") func testRegexFallback() {
func regexFallback() {
let output = """ let output = """
No of test patches = 10 No of test patches = 10
avg = 4.25 avg = 4.25
@@ -45,27 +41,25 @@ struct ProfcheckParserTests {
rms = 2.30 rms = 2.30
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.avgDE == 4.25) XCTAssertEqual(report.avgDE, 4.25)
#expect(report.maxDE == 6.10) XCTAssertEqual(report.maxDE, 6.10)
#expect(report.rmsDE == 2.30) XCTAssertEqual(report.rmsDE, 2.30)
#expect(report.status == .poor) XCTAssertEqual(report.status, .poor)
} }
@Test("Unparseable output warns, not zeros") func testUnparseable() {
func unparseable() {
let output = "some random text without metrics" let output = "some random text without metrics"
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == false) XCTAssertEqual(report.isValid, false)
#expect(report.warning != nil) XCTAssertNotNil(report.warning)
#expect(report.avgDE == nil) XCTAssertNil(report.avgDE)
} }
@Test("Status bands") func testStatusBands() {
func statusBands() { XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent)
#expect(VerificationStatus.from(avgDE: 0.5) == .excellent) XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good)
#expect(VerificationStatus.from(avgDE: 1.5) == .good) XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable)
#expect(VerificationStatus.from(avgDE: 2.5) == .acceptable) XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor)
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
} }
} }
+38 -52
View File
@@ -1,12 +1,11 @@
import Testing import Testing
import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("TargenArgs") final class TargenArgsTests: XCTestCase {
struct TargenArgsTests {
@Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4") func testRgbBaseline() throws {
func rgbBaseline() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -15,12 +14,11 @@ struct TargenArgsTests {
basename: "test_rgb" basename: "test_rgb"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"]) XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
#expect(!args.contains("-u")) XCTAssertFalse(args.contains("-u"))
} }
@Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0") func testCmykBaseline() throws {
func cmykBaseline() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 1500, patchCount: 1500,
@@ -29,11 +27,10 @@ struct TargenArgsTests {
basename: "test_cmyk" basename: "test_cmyk"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"]) XCTAssertEqual(args, ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
} }
@Test("Custom patch count honours -f (#44)") func testCustomPatchCount() throws {
func customPatchCount() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 2500, patchCount: 2500,
@@ -42,12 +39,11 @@ struct TargenArgsTests {
basename: "custom_patches" basename: "custom_patches"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args.contains("-f")) XCTAssertTrue(args.contains("-f"))
#expect(args[args.firstIndex(of: "-f")! + 1] == "2500") XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500")
} }
@Test("All advanced flags in stable order") func testAllAdvancedFlags() throws {
func allAdvancedFlags() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 1200, patchCount: 1200,
@@ -85,11 +81,10 @@ struct TargenArgsTests {
"-p", "2.00", "-p", "2.00",
"advanced_cmyk" "advanced_cmyk"
] ]
#expect(args == expected) XCTAssertEqual(args, expected)
} }
@Test("RGB ignores total ink limit") func testRgbIgnoresInkLimit() throws {
func rgbIgnoresInkLimit() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -99,11 +94,10 @@ struct TargenArgsTests {
basename: "rgb_no_ink" basename: "rgb_no_ink"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-l")) XCTAssertFalse(args.contains("-l"))
} }
@Test("Neutral concentration omitted when approximately 0.50") func testNeutralConcentrationOmittedWhenDefault() throws {
func neutralConcentrationOmittedWhenDefault() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -113,11 +107,10 @@ struct TargenArgsTests {
basename: "n_default" basename: "n_default"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-N")) XCTAssertFalse(args.contains("-N"))
} }
@Test("Adaptation emitted even at 0.10 (no default-skip)") func testAdaptationEmittedAtPointOne() throws {
func adaptationEmittedAtPointOne() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -127,12 +120,11 @@ struct TargenArgsTests {
basename: "a_flag" basename: "a_flag"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args.contains("-A")) XCTAssertTrue(args.contains("-A"))
#expect(args[args.firstIndex(of: "-A")! + 1] == "0.10") XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10")
} }
@Test("OFPS full spread algorithm emits no flag") func testOfpsEmitsNoFlag() throws {
func ofpsEmitsNoFlag() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -142,12 +134,11 @@ struct TargenArgsTests {
basename: "ofps_test" basename: "ofps_test"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("ofps")) XCTAssertFalse(args.contains("ofps"))
#expect(!args.contains("-t")) XCTAssertFalse(args.contains("-t"))
} }
@Test("Dark emphasis and device power omitted when 1.0") func testDarkEmphasisAndPowerOmittedWhenOne() throws {
func darkEmphasisAndPowerOmittedWhenOne() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -158,12 +149,11 @@ struct TargenArgsTests {
basename: "defaults_omitted" basename: "defaults_omitted"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-V")) XCTAssertFalse(args.contains("-V"))
#expect(!args.contains("-p")) XCTAssertFalse(args.contains("-p"))
} }
@Test("Whitespace-only preconditioning profile emits no -c") func testWhitespacePreconditioner() throws {
func whitespacePreconditioner() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -173,11 +163,10 @@ struct TargenArgsTests {
basename: "ws_pre" basename: "ws_pre"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-c")) XCTAssertFalse(args.contains("-c"))
} }
@Test("Preconditioning profile is trimmed before emission") func testPreconditionerTrimmed() throws {
func preconditionerTrimmed() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -187,11 +176,10 @@ struct TargenArgsTests {
basename: "trim_pre" basename: "trim_pre"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args[args.firstIndex(of: "-c")! + 1] == "/path/to/profile.icc") XCTAssertEqual(args[args.firstIndex(of: "-c")! + 1], "/path/to/profile.icc")
} }
@Test("Invalid basename throws") func testInvalidBasenameThrows() {
func invalidBasenameThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -199,13 +187,12 @@ struct TargenArgsTests {
blackPatches: 4, blackPatches: 4,
basename: "../bad_name" basename: "../bad_name"
) )
#expect(throws: PathSecurity.Error.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is PathSecurity.Error)
} }
} }
@Test("Invalid patch count throws") func testInvalidPatchCountThrows() {
func invalidPatchCountThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 0, patchCount: 0,
@@ -213,13 +200,12 @@ struct TargenArgsTests {
blackPatches: 4, blackPatches: 4,
basename: "bad_count" basename: "bad_count"
) )
#expect(throws: TargenArgError.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is TargenArgError)
} }
} }
@Test("Invalid ink limit throws for CMYK") func testInvalidInkLimitThrows() {
func invalidInkLimitThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 800, patchCount: 800,
@@ -228,8 +214,8 @@ struct TargenArgsTests {
totalInkLimit: 450, totalInkLimit: 450,
basename: "bad_ink" basename: "bad_ink"
) )
#expect(throws: TargenArgError.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is TargenArgError)
} }
} }
} }
@@ -1,4 +1,3 @@
import Testing
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
@@ -0,0 +1,21 @@
import XCTest
extension XCTestCase {
func assertAsyncThrows<T, E: Error>(
expectedType: E.Type,
_ expression: () async throws -> T,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath,
line: UInt = #line,
errorHandler: ((E) -> Void)? = nil
) async {
do {
_ = try await expression()
XCTFail("Expected \(expectedType) to be thrown but expression succeeded. \(message())", file: file, line: line)
} catch let error as E {
errorHandler?(error)
} catch {
XCTFail("Expected \(expectedType) but caught \(type(of: error)): \(error). \(message())", file: file, line: line)
}
}
}
+12 -12
View File
@@ -2,7 +2,7 @@ name: ICCery
options: options:
bundleIdPrefix: com.gronod bundleIdPrefix: com.gronod
deploymentTarget: deploymentTarget:
macOS: "14.0" macOS: "12.0"
groupSortPosition: top groupSortPosition: top
packages: packages:
@@ -13,7 +13,7 @@ targets:
ICCery: ICCery:
type: application type: application
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Sources/ICCery - path: Sources/ICCery
- path: Resources - path: Resources
@@ -46,7 +46,7 @@ targets:
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: ICCery INFOPLIST_KEY_CFBundleDisplayName: ICCery
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0" MARKETING_VERSION: "2.0.0"
@@ -58,15 +58,15 @@ targets:
ENABLE_APP_SANDBOX: NO ENABLE_APP_SANDBOX: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
SWIFT_STRICT_CONCURRENCY: complete OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ARCHS: "$(ARCHS_STANDARD)" ARCHS: "$(ARCHS_STANDARD)"
ICCeryCoreTests: ICCeryCoreTests:
type: bundle.unit-test type: bundle.unit-test
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Tests/ICCeryCoreTests - path: Tests/ICCeryCoreTests
dependencies: dependencies:
@@ -79,13 +79,13 @@ targets:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery" TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ICCeryUITests: ICCeryUITests:
type: bundle.ui-testing type: bundle.ui-testing
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Tests/ICCeryUITests - path: Tests/ICCeryUITests
dependencies: dependencies:
@@ -95,8 +95,8 @@ targets:
TEST_TARGET_NAME: ICCery TEST_TARGET_NAME: ICCery
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
schemes: schemes:
ICCery: ICCery: