Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a30a8fc551 test(m9): complete migration of all remaining test suites to XCTest
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:29:16 +01:00
gronod 83f3a4f0e2 Merge pull request 'test(m9): convert pure core suites to XCTest (Slice 2)' (#106) from feat/m9-slice2-xctest-core-pure into milestone/m9-monterey 2026-09-11 21:06:33 +01:00
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
50 changed files with 1496 additions and 1767 deletions
+10 -2
View File
@@ -14,13 +14,21 @@ jobs:
build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline.
runs-on: macos-14
runs-on: macos-12
env:
DERIVED: build/DerivedData-test
steps:
- name: Checkout
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
run: xcodegen generate --spec project.yml
@@ -120,7 +128,7 @@ jobs:
package:
needs: build-and-test
runs-on: macos-14
runs-on: macos-12
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 6.0
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "ICCeryCore",
platforms: [.macOS(.v14)],
platforms: [.macOS(.v12)],
products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
],
@@ -200,7 +200,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id)
var attempts = 0
while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100))
try? await Task.sleep(nanoseconds: 100_000_000)
attempts += 1
}
}
@@ -243,7 +243,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines {
dotFlushTask = Task { [processManager] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
try? await Task.sleep(nanoseconds: 500_000_000)
if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId)
}
@@ -592,7 +592,7 @@ public struct ArgyllRunner: Sendable {
await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY {
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(
_ contents: String,
sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile }
) throws -> CGATSDataset {
guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -101,10 +101,10 @@ public enum CGATSParser {
}
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat
throw CGATSParseError.missingBeginDataFormat
}
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData
throw CGATSParseError.missingBeginData
}
let rawFieldNames = splitFields(lines[formatStart + 1])
@@ -139,7 +139,7 @@ public enum CGATSParser {
let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex])
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)
@@ -153,7 +153,7 @@ public enum CGATSParser {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
}
} else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
}
} else {
sample.strings[name] = raw
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess(
_ contents: String,
isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
) throws -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines)
var lines = [String]()
@@ -239,7 +239,7 @@ public enum CGATSParser {
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
// 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
// arrive after the process exits (e.g. a hung pipe).
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 }
await self.forceKill(id: id)
await self.forceFinalize(id: id)
+1 -1
View File
@@ -33,7 +33,7 @@ final class SettingsViewModel {
sink.applySettings(settings)
savedFlash = true
Task {
try? await Task.sleep(for: .seconds(1.5))
try? await Task.sleep(nanoseconds: 1_500_000_000)
savedFlash = false
}
return true
+1 -1
View File
@@ -201,7 +201,7 @@ final class WizardViewModel {
self.notice = notice
if let delay = notice.autoHideAfter {
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 }
if self?.notice?.id == notice.id {
self?.notice = nil
+13 -15
View File
@@ -1,27 +1,25 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@Suite("AppPaths")
struct AppPathsTests {
@Test func appDataDirUsesBundleID() {
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
final class AppPathsTests: XCTestCase {
func testAppDataDirUsesBundleID() {
XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
}
@Test func logFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
func testLogFileIsUnderLibraryLogs() {
XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
}
@Test func bundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
func testBundledArgyllDirIsInsideResources() {
XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
}
}
@Suite("WizardStage")
struct WizardStageTests {
@Test func stepperOrderIsOneThroughFive() {
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
#expect(WizardStage.calibrate.stepperIndex == nil)
final class WizardStageTests: XCTestCase {
func testStepperOrderIsOneThroughFive() {
XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
XCTAssertNil(WizardStage.calibrate.stepperIndex)
}
}
@@ -1,22 +1,19 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ApplycalArgs")
struct ApplycalArgsTests {
final class ApplycalArgsTests: XCTestCase {
@Test("Apply argv")
func applyArgv() throws {
func testApplyArgv() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
)
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 unapplyEmittedWhenConfigSet() throws {
func testUnapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
@@ -25,6 +22,6 @@ struct ApplycalArgsTests {
let args = try ApplycalArgs.build(config: config)
// Builder emits -u only when the caller explicitly sets unapply.
// 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
@testable import ICCeryCore
@Suite("ArgsBuilder")
struct ArgsBuilderTests {
final class ArgsBuilderTests: XCTestCase {
// MARK: - option
@Test("option: nil emits nothing")
func optionNil() {
#expect(ArgsBuilder.option("-f", nil) == [])
func testOptionNil() {
XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
}
@Test("option: present value emits flag and value verbatim")
func optionPresent() {
#expect(ArgsBuilder.option("-f", "abc") == ["-f", "abc"])
#expect(ArgsBuilder.option("-f", "") == ["-f", ""])
#expect(ArgsBuilder.option("-f", " padded ") == ["-f", " padded "])
func testOptionPresent() {
XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
}
// MARK: - optionIfNonEmpty
@Test("optionIfNonEmpty: nil and empty emit nothing")
func optionIfNonEmptyNilEmpty() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", nil) == [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "") == [])
func testOptionIfNonEmptyNilEmpty() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
}
@Test("optionIfNonEmpty: whitespace-only emits nothing")
func optionIfNonEmptyWhitespace() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", " ") == [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " \t\n ") == [])
func testOptionIfNonEmptyWhitespace() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
}
@Test("optionIfNonEmpty: trims surrounding whitespace")
func optionIfNonEmptyTrims() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", " label ") == ["-d", "label"])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n") == ["-d", "cal.cal"])
func testOptionIfNonEmptyTrims() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
}
// MARK: - optionUnlessApprox
@Test("optionUnlessApprox: nil emits nothing")
func optionUnlessApproxNil() {
#expect(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50) == [])
func testOptionUnlessApproxNil() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
}
@Test("optionUnlessApprox: exact skip value emits nothing")
func optionUnlessApproxExactSkip() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50) == [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0) == [])
func testOptionUnlessApproxExactSkip() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
}
@Test("optionUnlessApprox: within epsilon emits nothing")
func optionUnlessApproxWithinEpsilon() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50) == [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0) == [])
func testOptionUnlessApproxWithinEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
}
@Test("optionUnlessApprox: outside epsilon emits flag")
func optionUnlessApproxOutsideEpsilon() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50) == ["-N", "0.75"])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0) == ["-V", "1.50"])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50) == ["-N", "0.50"])
func testOptionUnlessApproxOutsideEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
}
@Test("optionUnlessApprox: POSIX formatting is locale-stable")
func optionUnlessApproxPOSIX() {
func testOptionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal.
#expect(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", 1234.5, skip: 1.0), ["-p", "1234.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
}
@Test("optionUnlessApprox: custom epsilon and format honoured")
func optionUnlessApproxCustom() {
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01) == [])
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f") == ["-x", "1.5"])
func testOptionUnlessApproxCustom() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"])
}
// MARK: - flag
@Test("flag: true emits the bare flag")
func flagTrue() {
#expect(ArgsBuilder.flag("-G", when: true) == ["-G"])
#expect(ArgsBuilder.flag("-r", when: true) == ["-r"])
func testFlagTrue() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
}
@Test("flag: false emits nothing")
func flagFalse() {
#expect(ArgsBuilder.flag("-G", when: false) == [])
#expect(ArgsBuilder.flag("-r", when: false) == [])
func testFlagFalse() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
}
}
@@ -1,9 +1,8 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests {
final class ArgyllRunnerCalibrationTests: XCTestCase {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath)
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
return root
}
@Test("Calibration targen produces CAL_*.ti1")
func calibrationTargenProducesTi1() async throws {
func testCalibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let config = CalibrationTargenConfig(
@@ -36,13 +34,12 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
func calibrationTargenProcessId() async throws {
func testCalibrationTargenProcessId() async throws {
let testRoot = try makeTestDir()
let pm = ProcessManager()
let runner = makeRunner(processManager: pm)
@@ -65,13 +62,13 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_foo.ti1")
#expect(await sawExit.value)
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
let sawExitEvent = await sawExit.value
XCTAssertTrue(sawExitEvent)
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal captured run creates .cal")
func printcalProducesCal() async throws {
func testPrintcalProducesCal() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
@@ -83,13 +80,12 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runPrintcal(config: config)
#expect(url.lastPathComponent == "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal failure throws toolFailed")
func printcalFailureThrows() async throws {
func testPrintcalFailureThrows() async throws {
let testRoot = try makeTestDir()
defer { try? FileManager.default.removeItem(at: testRoot) }
@@ -117,9 +113,11 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output
)
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
_ = try await runner.runPrintcal(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
}
}
}
@@ -1,5 +1,5 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
final class LogHolder: @unchecked Sendable {
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
}
}
@Suite("ArgyllRunner colprof")
struct ArgyllRunnerColprofTests {
final class ArgyllRunnerColprofTests: XCTestCase {
@Test("Mock colprof produces .icc")
func colprofProducesIcc() async throws {
func testColprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
@@ -43,15 +41,14 @@ struct ArgyllRunnerColprofTests {
holder.append(batch)
}
#expect(url.lastPathComponent == "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
XCTAssertEqual(url.lastPathComponent, "testrun.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot)
}
@Test("Failing colprof throws toolFailed with code and logs")
func colprofFailureThrowsToolFailed() async throws {
func testColprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -72,9 +69,11 @@ struct ArgyllRunnerColprofTests {
)
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"])) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runColprof(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"]))
}
}
}
@@ -1,5 +1,5 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
/// Focused contracts for the shared `runStreamingTool` loop (#79).
@@ -7,8 +7,7 @@ import Testing
/// Every test uses a per-test temporary directory, unique basenames,
/// and a fresh `ProcessManager` no shared UI fixture scripts and no
/// process-environment mutation.
@Suite("ArgyllRunner streaming loop contracts")
struct ArgyllRunnerStreamingLoopTests {
final class ArgyllRunnerStreamingLoopTests: XCTestCase {
private func makeTempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -30,8 +29,7 @@ struct ArgyllRunnerStreamingLoopTests {
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
}
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines")
func nonZeroExitThrowsToolFailed() async throws {
func testNonZeroExitThrowsToolFailed() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
@@ -47,21 +45,20 @@ struct ArgyllRunnerStreamingLoopTests {
do {
_ = try await runner.runTargen(config: config)
Issue.record("Expected toolFailed")
XCTFail("Expected toolFailed")
} catch let error as ArgyllRunnerError {
guard case .toolFailed(let tool, let code, let logs) = error else {
Issue.record("Expected toolFailed, got \(error)")
XCTFail("Expected toolFailed, got \(error)")
return
}
#expect(tool == "targen")
#expect(code == 3)
#expect(logs.contains("Generating patches..."))
#expect(logs.contains("targen: too few patches"))
XCTAssertEqual(tool, "targen")
XCTAssertEqual(code, 3)
XCTAssertTrue(logs.contains("Generating patches..."))
XCTAssertTrue(logs.contains("targen: too few patches"))
}
}
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path")
func zeroExitMissingArtefact() async throws {
func testZeroExitMissingArtefact() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
@@ -75,13 +72,14 @@ struct ArgyllRunnerStreamingLoopTests {
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "gone", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(expectedPath))
}
}
@Test("Immediate exit after one stdout line still delivers the line and succeeds")
func immediateExitDeliversLine() async throws {
func testImmediateExitDeliversLine() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
@@ -101,13 +99,12 @@ struct ArgyllRunnerStreamingLoopTests {
let url = try await runner.runTargen(config: config) { batch in
holder.append(batch)
}
#expect(url.lastPathComponent == "quick.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("only line"))
XCTAssertEqual(url.lastPathComponent, "quick.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("only line"))
}
@Test("colprof unterminated progress fragment reaches onLogBatch before exit")
func colprofPartialLineFlush() async throws {
func testColprofPartialLineFlush() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
// The fragment is printed without a newline, then the mock sleeps
@@ -129,13 +126,13 @@ struct ArgyllRunnerStreamingLoopTests {
let url = try await runner.runColprof(config: config) { batch in
holder.append(batch)
}
#expect(url.lastPathComponent == "frag.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("Doing gamut mapping"))
XCTAssertEqual(url.lastPathComponent, "frag.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
}
@Test("toolFailed maps each tool to its user-facing description",
arguments: [
func testToolDescriptions() {
let cases: [(tool: String, expected: String)] = [
(tool: "chartread", expected: "Chartread failed: boom"),
(tool: "average", expected: "Averaging failed: boom"),
(tool: "colprof", expected: "Profile creation failed: boom"),
@@ -143,19 +140,19 @@ struct ArgyllRunnerStreamingLoopTests {
(tool: "applycal", expected: "Apply calibration failed: boom"),
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
(tool: "profcheck", expected: "Profile verification failed: boom"),
])
func toolDescriptions(tool: String, expected: String) {
]
for (tool, expected) in cases {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
#expect(error.errorDescription == expected)
XCTAssertEqual(error.errorDescription, expected)
}
}
@Test("toolFailed falls back to a generic description for unmapped tools and empty logs")
func genericFallbacks() {
func testGenericFallbacks() {
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
#expect(unknown.errorDescription == "Process exited with code 7")
XCTAssertEqual(unknown.errorDescription, "Process exited with code 7")
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
#expect(emptyLogs.errorDescription
== "Profile creation failed: exited with code 2")
XCTAssertEqual(emptyLogs.errorDescription,
"Profile creation failed: exited with code 2")
}
}
+46 -60
View File
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
import ImageIO
import UniformTypeIdentifiers
@@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL {
.appendingPathComponent(name)
}
@Suite("Ti2Header")
struct Ti2HeaderTests {
@Test func parsesKeywordsAndSibling() throws {
final class Ti2HeaderTests: XCTestCase {
func testParsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -31,18 +30,18 @@ struct Ti2HeaderTests {
)
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO")
#expect(h.patchCount == 800)
#expect(h.pageCount == 3)
#expect(h.hasSiblingTi1)
XCTAssertEqual(h.instrument, "i1iO")
XCTAssertEqual(h.patchCount, 800)
XCTAssertEqual(h.pageCount, 3)
XCTAssertTrue(h.hasSiblingTi1)
}
@Test func missingFileYieldsEmptyHeader() {
func testMissingFileYieldsEmptyHeader() {
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")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
@@ -50,12 +49,11 @@ struct Ti2HeaderTests {
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8
)
#expect(Ti2Header.parse(url).patchCount == 52)
XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
}
}
@Suite("TiffPreview")
struct TiffPreviewTests {
final class TiffPreviewTests: XCTestCase {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif")
@@ -81,50 +79,48 @@ struct TiffPreviewTests {
return url
}
@Test func producesCappedPNG() throws {
func testProducesCappedPNG() throws {
let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil)
XCTAssertNotNil(png)
// 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.
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200)
XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
XCTAssertEqual(img.width, 1200)
}
@Test func nonTiffReturnsNil() throws {
func testNonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
#expect(TiffPreview.previewPNG(tiff: url) == nil)
XCTAssertNil(TiffPreview.previewPNG(tiff: url))
}
}
@Suite("ArtefactFiles")
struct ArtefactFilesTests {
@Test func base64RoundTrip() throws {
final class ArtefactFilesTests: XCTestCase {
func testBase64RoundTrip() throws {
let url = tempURL("a.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url)
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
}
@Test func defaultWorkingDirExists() {
#expect(FileManager.default.fileExists(
func testDefaultWorkingDirExists() {
XCTAssertTrue(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path
))
}
}
@Suite("ArtefactProbe profile resolve")
struct ArtefactProbeProfileTests {
final class ArtefactProbeProfileTests: XCTestCase {
private func makeDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)")
@@ -134,93 +130,83 @@ struct ArtefactProbeProfileTests {
// MARK: Basename probe matrix (#69)
@Test("basename probe: only .icc exists")
func onlyIcc() throws {
func testOnlyIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.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 onlyIcm() throws {
func testOnlyIcm() throws {
let dir = try makeDir()
let icm = dir.appendingPathComponent("job.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 icmWins() throws {
func testIcmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
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 neitherExists() throws {
func testNeitherExists() throws {
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)
@Test("explicit existing .icc wins even when .icm exists")
func explicitIccWins() throws {
func testExplicitIccWins() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
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 explicitIcmWins() throws {
func testExplicitIcmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.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 flipExtension() throws {
func testFlipExtension() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let resolved = ArtefactProbe.resolveProfile(icc)
#expect(resolved.path == icm.path)
XCTAssertEqual(resolved.path, icm.path)
}
@Test("explicit missing .icm flips to sibling .icc")
func flipToIcc() throws {
func testFlipToIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
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 missingBoth() throws {
func testMissingBoth() throws {
let dir = try makeDir()
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 unrelatedExtension() throws {
func testUnrelatedExtension() throws {
let dir = try makeDir()
let mpp = dir.appendingPathComponent("job.mpp")
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
// 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")
#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
@testable import ICCeryCore
@Suite("BinaryResolver")
struct BinaryResolverTests {
final class BinaryResolverTests: XCTestCase {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory
@@ -22,16 +21,16 @@ struct BinaryResolverTests {
}
}
@Test func overrideDirWinsWhenFileExists() throws {
func testOverrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in
try touch(root.appendingPathComponent("targen"))
}
let bundled = try makeTree { _ in }
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 bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal")
@@ -39,10 +38,10 @@ struct BinaryResolverTests {
try touch(dir.appendingPathComponent("instlist"))
}
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
for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir)
@@ -51,10 +50,10 @@ struct BinaryResolverTests {
}
}
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 d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
@@ -64,20 +63,20 @@ struct BinaryResolverTests {
bundledRoot: bundled,
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 r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen")))
XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
XCTAssertFalse(r.exists(r.resolve("targen")))
}
@Test func mockAndGamutPaths() throws {
func testMockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
}
}
+37 -50
View File
@@ -1,9 +1,8 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CGATS Parser & Writer")
struct CGATSParserTests {
final class CGATSParserTests: XCTestCase {
private static let canonicalCTI3 = """
CTI3
@@ -21,44 +20,40 @@ struct CGATSParserTests {
END_DATA
"""
@Test("Parses CTI3 with canonical field names")
func parseCTI3() throws {
func testParseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3)
#expect(dataset.samples.count == 2)
#expect(dataset.colorRep == "RGB")
#expect(dataset.deviceClass == "DISPLAY")
#expect(dataset.samples[0].id == "1")
#expect(dataset.samples[0].loc == "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
XCTAssertEqual(dataset.format, .cti3)
XCTAssertEqual(dataset.samples.count, 2)
XCTAssertEqual(dataset.colorRep, "RGB")
XCTAssertEqual(dataset.deviceClass, "DISPLAY")
XCTAssertEqual(dataset.samples[0].id, "1")
XCTAssertEqual(dataset.samples[0].loc, "A1")
XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
}
@Test("Round-trips parse, write, reparse")
func roundTrip() throws {
func testRoundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text)
#expect(second.format == first.format)
#expect(second.samples.count == first.samples.count)
#expect(second.colorRep == first.colorRep)
#expect(second.deviceClass == first.deviceClass)
XCTAssertEqual(second.format, first.format)
XCTAssertEqual(second.samples.count, first.samples.count)
XCTAssertEqual(second.colorRep, first.colorRep)
XCTAssertEqual(second.deviceClass, first.deviceClass)
}
@Test("Parses CSV with comma delimiters")
func parseCSV() throws {
func testParseCSV() throws {
let csv = """
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
2,A2,0,50,0,10,30,5,60,-50,40
"""
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
#expect(dataset.format == .csv)
#expect(dataset.samples.count == 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
XCTAssertEqual(dataset.format, .csv)
XCTAssertEqual(dataset.samples.count, 2)
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
}
@Test("Converts 0-255 device values to 0-100")
func converts255To100() throws {
func testConverts255To100() throws {
let rgb = """
CTI3
COLOR_REP RGB
@@ -72,12 +67,11 @@ struct CGATSParserTests {
END_DATA
"""
let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
}
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
func synthesizesMetadata() throws {
func testSynthesizesMetadata() throws {
let cmyk = """
CTI3
NUMBER_OF_FIELDS 6
@@ -90,19 +84,15 @@ struct CGATSParserTests {
END_DATA
"""
let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK")
#expect(dataset.deviceClass == "PRINTER")
XCTAssertEqual(dataset.colorRep, "CMYK")
XCTAssertEqual(dataset.deviceClass, "PRINTER")
}
@Test("Rejects empty file")
func rejectsEmpty() {
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
func testRejectsEmpty() {
XCTAssertThrowsError(try CGATSParser.parse(""))
}
@Test("Rejects malformed arity")
func rejectsArity() {
func testRejectsArity() {
let bad = """
CTI3
NUMBER_OF_FIELDS 2
@@ -114,21 +104,18 @@ struct CGATSParserTests {
1
END_DATA
"""
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse(bad)
}
XCTAssertThrowsError(try CGATSParser.parse(bad))
}
@Test("Writer emits valid .ti3 with tabs and required keywords")
func writerFormat() throws {
func testWriterFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset)
#expect(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t"))
XCTAssertTrue(text.contains("CTI3"))
XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
XCTAssertTrue(text.contains("BEGIN_DATA"))
XCTAssertTrue(text.contains("END_DATA"))
XCTAssertTrue(text.contains("COLOR_REP"))
XCTAssertTrue(text.contains("DEVICE_CLASS"))
XCTAssertTrue(text.contains("\t"))
}
}
@@ -1,78 +1,67 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
/// Issue #83 canonical `CAL_` / original-stem pairing.
@Suite("CalibrationIdentity")
struct CalibrationIdentityTests {
@Test("live foo, no persisted")
func livePlain() {
final class CalibrationIdentityTests: XCTestCase {
func testLivePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("live foo ignores stale persisted")
func livePlainIgnoresPersisted() {
func testLivePlainIgnoresPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("live CAL_foo, persisted foo")
func liveCalPersisted() {
func testLiveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("live CAL_foo, empty persisted strips prefix")
func liveCalNoPersist() {
func testLiveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("persisted original wins over CAL_ live")
func persistedWins() {
func testPersistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
#expect(id.originalBasename == "bar")
#expect(id.calibrationBasename == "CAL_bar")
XCTAssertEqual(id.originalBasename, "bar")
XCTAssertEqual(id.calibrationBasename, "CAL_bar")
}
@Test("empty live yields empty identity even with persisted original")
func emptyLiveWithPersisted() {
func testEmptyLiveWithPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
XCTAssertTrue(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty)
}
@Test("empty live, empty persisted")
func emptyLive() {
func testEmptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
XCTAssertTrue(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty)
}
@Test("prefix is idempotent on already-prefixed input")
func alreadyPrefixed() {
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
func testAlreadyPrefixed() {
XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo")
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "CAL_foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "CAL_foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("prefix never invents a name from empty input")
func prefixEmpty() {
#expect(CalibrationIdentity.prefix("").isEmpty)
#expect(CalibrationIdentity.strip("foo") == "foo")
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
func testPrefixEmpty() {
XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
}
@Test("runner process id for a calibration targen is targen_CAL_*")
func processIdMatches() {
func testProcessIdMatches() {
let cal = CalibrationIdentity.prefix("foo")
#expect(ProcessID.targen(cal) == "targen_CAL_foo")
XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
}
}
@@ -1,9 +1,8 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CalibrationStore")
struct CalibrationStoreTests {
final class CalibrationStoreTests: XCTestCase {
private static let sampleCal = """
CTI3
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
END_DATA
"""
@Test("Loads metadata and curves from .cal")
func parseCal() async throws {
func testParseCal() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
try await store.load(url: url)
let data = await store.data
#expect(data?.colorRep == "RGB")
#expect(data?.descriptor == "Test printer")
#expect(data?.maxTac == 300)
#expect(data?.curves.count == 3)
XCTAssertEqual(data?.colorRep, "RGB")
XCTAssertEqual(data?.descriptor, "Test printer")
XCTAssertEqual(data?.maxTac, 300)
XCTAssertEqual(data?.curves.count, 3)
let r = data?.curves.first { $0.channel == "R" }
#expect(r?.output == [0, 64, 255])
XCTAssertEqual(r?.output, [0, 64, 255])
}
@Test("Staleness is true for a very old calibration")
func staleCalibration() async throws {
func testStaleCalibration() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
let store = CalibrationStore(staleDays: 0)
try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other")
#expect(stale == true)
XCTAssertEqual(stale, true)
}
@Test("Printer mismatch is flagged as stale")
func printerMismatch() async throws {
func testPrinterMismatch() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
try await store.load(url: url)
await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B")
#expect(stale == true)
XCTAssertEqual(stale, true)
}
}
@@ -1,12 +1,10 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CalibrationTargenArgs")
struct CalibrationTargenArgsTests {
final class CalibrationTargenArgsTests: XCTestCase {
@Test("RGB baseline")
func rgbBaseline() throws {
func testRgbBaseline() throws {
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
@@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp")
)
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 cmykWithOptions() throws {
func testCmykWithOptions() throws {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
steps: 25,
@@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp")
)
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 rejectsBadSteps() {
func testRejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
}
@Test("Rejects bad CMYK ink limit")
func rejectsBadInkLimit() {
func testRejectsBadInkLimit() {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
inkLimit: 500,
basename: "demo"
)
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
}
@Test("Does not double-prefix an existing CAL_ basename")
func noDoublePrefix() throws {
func testNoDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test")
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 Testing
import XCTest
@testable import ICCeryCore
@Suite("ColprofArgs")
struct ColprofArgsTests {
final class ColprofArgsTests: XCTestCase {
@Test("Default algorithm and quality")
func defaults() throws {
func testDefaults() throws {
let config = ColprofConfig(basename: "target")
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 fwaBareFlag() throws {
func testFwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target")
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 fwaD50() throws {
func testFwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-f"))
#expect(args.contains("D50"))
#expect(args.last == "target")
XCTAssertTrue(args.contains("-f"))
XCTAssertTrue(args.contains("D50"))
XCTAssertEqual(args.last, "target")
}
@Test("FWA none is omitted")
func fwaNoneOmitted() throws {
func testFwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-f"))
XCTAssertFalse(args.contains("-f"))
}
@Test("Viewing conditions skip none")
func viewingCondNoneSkipped() throws {
func testViewingCondNoneSkipped() throws {
let config = ColprofConfig(
inputViewingCond: "none",
outputViewingCond: "mt",
basename: "target"
)
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-c"))
#expect(args.contains("-d"))
#expect(args.contains("mt"))
XCTAssertFalse(args.contains("-c"))
XCTAssertTrue(args.contains("-d"))
XCTAssertTrue(args.contains("mt"))
}
@Test("Description falls back to basename when empty")
func descriptionFallback() throws {
func testDescriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-D"))
XCTAssertFalse(args.contains("-D"))
}
@Test("Copyright only when non-empty")
func copyright() throws {
func testCopyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-C"))
#expect(args.contains("Gronod 2026"))
XCTAssertTrue(args.contains("-C"))
XCTAssertTrue(args.contains("Gronod 2026"))
}
@Test("No -u passed")
func noProgressJsonFlag() throws {
func testNoProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-u"))
XCTAssertFalse(args.contains("-u"))
}
}
@@ -1,24 +1,20 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ColprofProgress")
struct ColprofProgressTests {
final class ColprofProgressTests: XCTestCase {
@Test("Classifies gamut mapping")
func gamutMapping() {
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
func testGamutMapping() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
}
@Test("Classifies fitting or clut")
func fitting() {
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
func testFitting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
}
@Test("Classifies writing")
func writing() {
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
func testWriting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
}
}
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@testable import ICCery
@@ -6,48 +6,42 @@ import AppKit
import ApplicationServices
/// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ).
@Suite("CupsOptionsFilter")
struct CupsOptionsFilterTests {
final class CupsOptionsFilterTests: XCTestCase {
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
func dropsReserved() {
func testDropsReserved() {
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
+ "collate=true copies=1 job-sheets=none,none "
+ "pserrorhandler-requested=standard "
+ "MediaType=PhotographicGlossy"
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
}
@Test("Keeps relevant driver keys, order preserved")
func keepsRelevant() {
func testKeepsRelevant() {
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
+ "Resolution=600x600dpi Duplex=None"
#expect(CupsOptionsFilter.filter(raw) == raw)
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
}
@Test("Permissive: unknown non-com.* keys survive")
func keepsUnknown() {
func testKeepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain"
#expect(CupsOptionsFilter.filter(raw) == raw)
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
}
@Test("Drops empty keys and values")
func dropsEmpty() {
func testDropsEmpty() {
let raw = "=noval MediaType= InputSlot=Rear"
// "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 extractMedia() {
#expect(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7") == "7")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4") == nil)
func testExtractMedia() {
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7"), "7")
XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4"))
}
}
@@ -55,9 +49,8 @@ struct CupsOptionsFilterTests {
/// `@convention(c)` closures can't capture, so recording goes through
/// a file-scope recorder keyed by global state; no private symbols are
/// touched.
@Suite("ColorSyncSuppressor")
@MainActor
struct ColorSyncSuppressorTests {
final class ColorSyncSuppressorTests: XCTestCase {
/// Fake PMPrintSession the injected resolver never dereferences it.
private var fakeSession: PMPrintSession {
@@ -90,55 +83,50 @@ struct ColorSyncSuppressorTests {
return s
}
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
func attemptOrder() {
func testAttemptOrder() {
Self.recorded = []
Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"]
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.
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
== ColorMatchingAttempts.attempts
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" })
}
@Test("First zero wins — later symbols/modes not called")
func firstZeroWins() {
func testFirstZeroWins() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"AP_ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
XCTAssertTrue(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
])
}
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
func modeFallback() {
func testModeFallback() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
#expect(Self.recorded.count == 2)
XCTAssertTrue(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
XCTAssertEqual(Self.recorded.count, 2)
}
@Test("All symbols missing → false, no calls")
func allMissing() {
func testAllMissing() {
Self.recorded = []
Self.succeeding = nil
Self.missing = Set(ColorMatchingAttempts.symbols)
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false)
#expect(Self.recorded.isEmpty)
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
XCTAssertTrue(Self.recorded.isEmpty)
}
}
+51 -62
View File
@@ -1,11 +1,10 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
/// Issue 12 CUPS enumeration parsers on recorded fixtures
/// (docs/1011). No live `lpstat`/`lpoptions` is spawned here.
@Suite("CupsParsers")
struct CupsParsersTests {
final class CupsParsersTests: XCTestCase {
// Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """
@@ -34,112 +33,102 @@ struct CupsParsersTests {
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
"""
@Test("lpstat -e: one destination per line; empty = success")
func destinations() {
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
func testDestinations() {
XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
"Canon_Pro9500_II_series_XPS",
"Epson_XP_55_LPD",
"EPSON_XP_55_Series",
])
#expect(CupsParsers.lpstatDestinations("") == [])
XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
}
@Test("lpstat -p: idle / now-printing / disabled statuses")
func statuses() {
func testStatuses() {
let s = CupsParsers.lpstatStatuses(lpstatP)
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
#expect(s["Epson_XP_55_LPD"] == .printing)
#expect(s["EPSON_XP_55_Series"] == .stopped)
XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
}
@Test("lpstat -d: default destination or none")
func defaultDestination() {
#expect(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n")
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
func testDefaultDestination() {
XCTAssertEqual(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
}
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
func displayName() {
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
func testDisplayName() {
XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
}
@Test("lpoptions -l: key/label split, * marks the default")
func optionListings() {
func testOptionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL)
#expect(listings.count == 6)
XCTAssertEqual(listings.count, 6)
let page = listings[0]
#expect(page.key == "PageSize")
#expect(page.label == "Media Size")
#expect(page.defaultChoice == "A4")
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
#expect(!page.choices.contains("*A4"))
XCTAssertEqual(page.key, "PageSize")
XCTAssertEqual(page.label, "Media Size")
XCTAssertEqual(page.defaultChoice, "A4")
XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
XCTAssertFalse(page.choices.contains("*A4"))
let slot = listings[1]
#expect(slot.key == "InputSlot")
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
#expect(slot.defaultChoice == "Main")
XCTAssertEqual(slot.key, "InputSlot")
XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
XCTAssertEqual(slot.defaultChoice, "Main")
}
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
func capabilities() {
func testCapabilities() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil)
#expect(caps.trays == [
XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"),
])
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
#expect(caps.paperSizes.count == 10)
#expect(caps.mediaTypes.map(\.id) == [
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
XCTAssertEqual(caps.paperSizes.count, 10)
XCTAssertEqual(caps.mediaTypes.map(\.id), [
"Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope",
])
#expect(caps.supportsOrientation)
XCTAssertTrue(caps.supportsOrientation)
}
@Test("PPD enrichment maps id → human label")
func ppdLabels() {
func testPpdLabels() {
let ppd = """
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
*CNIJMediaType 0/Plain Paper: ""
*en_US.CNIJMediaType 13/Envelope: ""
"""
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
#expect(labels["0"] == "Plain Paper")
#expect(labels["13"] == "Envelope")
XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
XCTAssertEqual(labels["0"], "Plain Paper")
XCTAssertEqual(labels["13"], "Envelope")
}
@Test("detectMediaTypeKey prefers vendor keys in order")
func mediaTypeKey() {
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
func testMediaTypeKey() {
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]), "MediaType")
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
}
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
func driverBypass() {
func testDriverBypass() {
func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys)
.map { "\($0.key)=\($0.value)" }
}
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
#expect(pair(["PageSize"]) == nil)
XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
XCTAssertNil(pair(["PageSize"]))
}
}
+20 -30
View File
@@ -1,65 +1,57 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("DriftAlert")
struct DriftAlertTests {
final class DriftAlertTests: XCTestCase {
@Test("No alert with fewer than two poor results")
func notEnough() {
func testNotEnough() {
let records = [
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 oneHourApart() {
func testOneHourApart() {
let records = [
record(avg: 4.0, at: 1000),
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 sameDayUnderHour() {
func testSameDayUnderHour() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Alert on distinct days")
func distinctDays() {
func testDistinctDays() {
let day1 = record(avg: 4.0, at: 0)
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 nonPoor() {
func testNonPoor() {
let records = [
record(avg: 1.0, at: 0),
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 nonPoorBreaksRun() {
func testNonPoorBreaksRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good breaks the run
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 onlySuffixRun() {
func testOnlySuffixRun() {
let records = [
record(avg: 4.0, at: 0), // poor
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.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 suffixRunAlerts() {
func testSuffixRunAlerts() {
let records = [
record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor
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 singleFinalPoor() {
func testSingleFinalPoor() {
let records = [
record(avg: 1.0, at: 0),
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 {
+35 -38
View File
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
@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)
}
@Suite("PathSecurity")
struct PathSecurityTests {
@Test func rejectsTraversalAndSeparators() {
final class PathSecurityTests: XCTestCase {
func testRejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) {
try PathSecurity.sanitizeBasename(bad)
XCTAssertFalse(PathSecurity.isValidBasename(bad))
XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
XCTAssertTrue(error is PathSecurity.Error)
}
}
}
@Test func acceptsNormalNames() {
func testAcceptsNormalNames() {
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()
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
}
@Test func resolveSafeCwdNeverReturnsNil() {
func testResolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
}
}
@Suite("AtomicFileWriter")
struct AtomicFileWriterTests {
@Test func writesAndLeavesNoTmp() throws {
final class AtomicFileWriterTests: XCTestCase {
func testWritesAndLeavesNoTmp() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
}
@Test func overwritesExistingAtomically() throws {
func testOverwritesExistingAtomically() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", 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 url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url)
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
}
}
@Suite("ArtefactProbe")
struct ArtefactProbeTests {
@Test func verifyProgression() throws {
final class ArtefactProbeTests: XCTestCase {
func testVerifyProgression() throws {
let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v == StageArtefacts())
XCTAssertEqual(v, StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1"))
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.ti3"))
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"))
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()
try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm"))
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()
for name in [
"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.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"))
#expect(!names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1"))
XCTAssertFalse(names.contains("other.ti1"))
XCTAssertFalse(names.contains("t.txt"))
XCTAssertFalse(names.contains("CAL_other.ti1"))
}
@Test func emptyDirReturnsEmpty() throws {
func testEmptyDirReturnsEmpty() throws {
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 ICCeryCore
@testable import ICCery
/// ``GamutSceneGeometryBuilder`` edge-case tests.
@Suite("Gamut scene geometry builder")
@MainActor
struct GamutGeometryBuilderTests {
final class GamutGeometryBuilderTests: XCTestCase {
@Test("Drops out-of-bounds faces from the element without crashing")
func dropsOutOfBoundsFaces() {
func testDropsOutOfBoundsFaces() {
let white = GamutVertex(
lab: LabColor(l: 100, a: 0, b: 0),
rgb: DisplayRGB(r: 1, g: 1, b: 1)
@@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests {
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 Testing
import XCTest
@testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests.
@Suite("Gamut mesh parser")
struct GamutMeshParserTests {
final class GamutMeshParserTests: XCTestCase {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL {
@@ -13,16 +12,14 @@ struct GamutMeshParserTests {
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
}
@Test("Parses bundled sRGB.gam")
func parsesBundledSRGB() throws {
func testParsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
}
@Test("Discards VERTEX_NO and uses push-order indices")
func discardsVertexNo() throws {
func testDiscardsVertexNo() throws {
let text = """
GAMUT
NUMBER_OF_FIELDS 4
@@ -49,14 +46,13 @@ struct GamutMeshParserTests {
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 4)
#expect(mesh.faces.count == 2)
#expect(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.count, 4)
XCTAssertEqual(mesh.faces.count, 2)
XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30))
XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60))
}
@Test("Ignores comments and blank lines")
func ignoresComments() throws {
func testIgnoresComments() throws {
let text = """
# Header comment
NUMBER_OF_FIELDS 4
@@ -81,12 +77,11 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 2)
#expect(mesh.faces.count == 1)
XCTAssertEqual(mesh.vertices.count, 2)
XCTAssertEqual(mesh.faces.count, 1)
}
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
func remapsCoordinates() throws {
func testRemapsCoordinates() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -99,11 +94,10 @@ struct GamutMeshParserTests {
"""
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 computesVertexColor() throws {
func testComputesVertexColor() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -116,14 +110,13 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
let white = try #require(mesh.vertices.first).rgb
#expect(white.r > 0.95)
#expect(white.g > 0.95)
#expect(white.b > 0.95)
let white = try XCTUnwrap(mesh.vertices.first).rgb
XCTAssertTrue(white.r > 0.95)
XCTAssertTrue(white.g > 0.95)
XCTAssertTrue(white.b > 0.95)
}
@Test("Drops out-of-bounds face indices")
func dropsOutOfBoundsFaces() throws {
func testDropsOutOfBoundsFaces() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -146,21 +139,23 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.faces.count == 1)
XCTAssertEqual(mesh.faces.count, 1)
}
@Test("Throws on empty file")
func throwsOnEmptyFile() {
#expect(throws: GamutMeshParseError.noDataBlock) {
_ = try GamutMeshParser.parse(text: "")
func testThrowsOnEmptyFile() {
XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
guard case GamutMeshParseError.noDataBlock = error else {
return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
}
}
}
@Test("Throws when file is missing")
func throwsWhenMissing() {
func testThrowsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
#expect(throws: GamutMeshParseError.missingFile) {
_ = try GamutMeshParser.parse(url: url)
XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
guard case GamutMeshParseError.missingFile = error else {
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
}
}
}
}
@@ -1,16 +1,14 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("IccgamutArgs")
struct IccgamutArgsTests {
final class IccgamutArgsTests: XCTestCase {
@Test("Density is 10 and not a directory")
func densityNotDirectory() throws {
func testDensityNotDirectory() throws {
let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
)
let args = try IccgamutArgs.build(config: config)
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
}
}
+14 -21
View File
@@ -1,26 +1,23 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("JSONFileStore")
struct JSONFileStoreTests {
final class JSONFileStoreTests: XCTestCase {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json")
}
@Test("Missing file returns default")
func missingFileDefaults() throws {
func testMissingFileDefaults() throws {
let store = JSONFileStore<AppSettings>(
fileURL: tempURL(),
corrupt: .throwCorrupt,
defaultValue: { .default }
)
#expect(try store.load() == .default)
XCTAssertEqual(try store.load(), .default)
}
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
func corruptDefaults() throws {
func testCorruptDefaults() throws {
let url = tempURL()
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<AppSettings>(
@@ -28,13 +25,12 @@ struct JSONFileStoreTests {
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
#expect(try store.load() == .default)
XCTAssertEqual(try store.load(), .default)
let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "{ not json")
XCTAssertEqual(kept, "{ not json")
}
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
func corruptThrows() throws {
func testCorruptThrows() throws {
let url = tempURL()
try "not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<[Int]>(
@@ -42,15 +38,12 @@ struct JSONFileStoreTests {
corrupt: .throwCorrupt,
defaultValue: { [] }
)
#expect(throws: DecodingError.self) {
_ = try store.load()
}
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json")
XCTAssertEqual(kept, "not json")
}
@Test("Pretty sorted keys")
func prettySorted() throws {
func testPrettySorted() throws {
let url = tempURL()
let store = JSONFileStore<AppSettings>(
fileURL: url,
@@ -59,8 +52,8 @@ struct JSONFileStoreTests {
)
try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains("\n"))
#expect(text.contains("\"delta_e_good_max\""))
XCTAssertTrue(text.contains("\n"))
XCTAssertTrue(text.contains("\"delta_e_good_max\""))
// Lexical key sorting: ascending order of top-level keys.
let keys = [
"ask_before_overwrite_profile",
@@ -75,7 +68,7 @@ struct JSONFileStoreTests {
var lastIndex = text.startIndex
for key in keys {
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
Issue.record("missing or out-of-order key \(key)")
XCTFail("missing or out-of-order key \(key)")
return
}
lastIndex = range.upperBound
+44 -57
View File
@@ -1,12 +1,11 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
/// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`).
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
/// flag order, captured-option precedence, and sanitisation.
@Suite("LpArgs")
struct LpArgsTests {
final class LpArgsTests: XCTestCase {
private let tiff = "/tmp/work/target_001.tif"
private let queue = "EPSON_XP_55_Series"
@@ -20,112 +19,100 @@ struct LpArgsTests {
options: options, optionKeys: optionKeys)
}
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
func header() throws {
func testHeader() throws {
let argv = try build()
#expect(Array(argv[0...1]) == ["-d", queue])
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
#expect(Array(argv[4...5])
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(Array(argv[6...7])
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(argv.last, tiff)
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
}
@Test("Never emits -o raw; captured raw= is dropped")
func neverRaw() throws {
func testNeverRaw() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" {
#expect(argv[i + 1] != "raw")
#expect(argv[i + 1] != "raw=true")
XCTAssertNotEqual(argv[i + 1], "raw")
XCTAssertNotEqual(argv[i + 1], "raw=true")
}
#expect(!argv.contains { $0.hasPrefix("raw=") })
#expect(argv.contains("MediaType=Photo"))
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
XCTAssertTrue(argv.contains("MediaType=Photo"))
}
@Test("Captured options replayed after AP_* headers")
func capturedReplay() throws {
func testCapturedReplay() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear MediaType=Photo"))
let rear = argv.firstIndex(of: "InputSlot=Rear")!
let apFirst = argv.firstIndex(of:
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
#expect(rear > apFirst)
XCTAssertTrue(rear > apFirst)
}
@Test("Captured wins: media key present → derived media skipped")
func capturedWinsMedia() throws {
func testCapturedWinsMedia() throws {
let argv = try build(
options: PrintOptions(
mediaType: "Plain",
cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"])
#expect(argv.contains("MediaType=Glossy"))
#expect(!argv.contains("MediaType=Plain"))
XCTAssertTrue(argv.contains("MediaType=Glossy"))
XCTAssertFalse(argv.contains("MediaType=Plain"))
}
@Test("Media emitted via detected key when not captured")
func mediaDerived() throws {
func testMediaDerived() throws {
let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order.
#expect(argv.contains("CNIJMediaType=SemiGloss"))
#expect(!argv.contains("MediaType=SemiGloss"))
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
}
@Test("Driver bypass emitted when absent, skipped when captured")
func bypassRules() throws {
func testBypassRules() throws {
let withBypass = try build(
optionKeys: ["EPIJ_CMat"])
#expect(withBypass.contains("EPIJ_CMat=3"))
XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
let captured = try build(
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
optionKeys: ["EPIJ_CMat"])
// Captured value kept, detection not re-applied.
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
== ["EPIJ_CMat=1"])
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
}
@Test("Orientation: portrait=3 landscape=4; captured wins")
func orientation() throws {
#expect(try build(options: PrintOptions(orientation: "portrait"))
func testOrientation() throws {
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3"))
#expect(try build(options: PrintOptions(orientation: "landscape"))
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions(
orientation: "landscape",
cupsOptions: "orientation-requested=5"))
#expect(!capturedOrients.contains("orientation-requested=4"))
#expect(capturedOrients.contains("orientation-requested=5"))
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
}
@Test("PageSize emitted unless captured")
func pageSize() throws {
#expect(try build(options: PrintOptions(paperSize: "A4"))
func testPageSize() throws {
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter"))
#expect(!capturedSize.contains("PageSize=A4"))
#expect(capturedSize.contains("PageSize=Letter"))
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
}
@Test("Sanitise rejects `;`, newline, and shell metachars")
func sanitise() throws {
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))
func testSanitise() throws {
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
XCTAssertTrue(error is LpArgsError)
}
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
XCTAssertTrue(error is LpArgsError)
}
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))) { error in
XCTAssertTrue(error is LpArgsError)
}
}
}
+93 -128
View File
@@ -1,12 +1,10 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("InstrumentParser")
struct InstrumentParserTests {
final class InstrumentParserTests: XCTestCase {
@Test("Parses pretty-printed instlist JSON")
func json() throws {
func testJson() throws {
let json = """
{
"event": "instruments",
@@ -18,134 +16,118 @@ struct InstrumentParserTests {
}
"""
let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3)
#expect(devices[0].port == 1)
#expect(devices[0].name == "X-Rite i1Pro")
#expect(devices[2].port == 3)
XCTAssertEqual(devices.count, 3)
XCTAssertEqual(devices[0].port, 1)
XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
XCTAssertEqual(devices[2].port, 3)
}
@Test("Falls back to regex for legacy instlist text")
func regexFallback() throws {
func testRegexFallback() throws {
let text = """
1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile'
""" + "\n"
let devices = try InstrumentParser.parse(text)
#expect(devices.count == 2)
#expect(devices[0].port == 1)
#expect(devices[1].name == "ColorMunki Smile")
XCTAssertEqual(devices.count, 2)
XCTAssertEqual(devices[0].port, 1)
XCTAssertEqual(devices[1].name, "ColorMunki Smile")
}
@Test("Empty output returns no devices")
func empty() throws {
#expect(try InstrumentParser.parse("").isEmpty)
func testEmpty() throws {
XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
}
}
@Suite("ChartreadArgs")
struct ChartreadArgsTests {
final class ChartreadArgsTests: XCTestCase {
@Test("Baseline argv and port 1 omits -c")
func baseline() throws {
func testBaseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "target"])
XCTAssertEqual(args, ["-v", "-u", "target"])
}
@Test("Port > 1 emits -c")
func portArgument() throws {
func testPortArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3)
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 leds() throws {
func testLeds() throws {
let config = ChartreadConfig(
basename: "target",
selectedPort: 2,
enableLEDs: true
)
let args = try ChartreadArgs.build(config: config)
#expect(args.contains("-Y"))
#expect(args.contains("l"))
XCTAssertTrue(args.contains("-Y"))
XCTAssertTrue(args.contains("l"))
}
@Test("Auto omits -c")
func autoPort() throws {
func testAutoPort() throws {
let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config)
#expect(!args.contains("-c"))
XCTAssertFalse(args.contains("-c"))
}
}
@Suite("ChartreadClassifier")
struct ChartreadClassifierTests {
final class ChartreadClassifierTests: XCTestCase {
@Test("Calibration prompt")
func calibration() {
func testCalibration() {
let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle
)
#expect(r.state == .calibrating)
XCTAssertEqual(r.state, .calibrating)
}
@Test("Strip awaiting")
func awaitingStrip() {
func testAwaitingStrip() {
let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A",
previousState: .calibrating
)
#expect(r.state == .awaitingStrip)
XCTAssertEqual(r.state, .awaitingStrip)
}
@Test("Done prompt")
func done() {
func testDone() {
let r = ChartreadClassifier.classify(
line: "'d' if/when done",
previousState: .awaitingStrip
)
#expect(r.state == .allStripsRead)
XCTAssertEqual(r.state, .allStripsRead)
}
@Test("XY place sheet")
func placeSheet() {
func testPlaceSheet() {
let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table",
previousState: .idle
)
#expect(r.state == .tablePlaceSheet)
#expect(r.sheetNumber == 1)
#expect(r.sheetTotal == 2)
XCTAssertEqual(r.state, .tablePlaceSheet)
XCTAssertEqual(r.sheetNumber, 1)
XCTAssertEqual(r.sheetTotal, 2)
}
@Test("XY locate patch")
func locatePatch() {
func testLocatePatch() {
let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet
)
#expect(r.state == .tableAlign)
#expect(r.alignmentPatch == "A1")
XCTAssertEqual(r.state, .tableAlign)
XCTAssertEqual(r.alignmentPatch, "A1")
}
@Test("Remove sheet notice preserves state")
func removeNotice() {
func testRemoveNotice() {
let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table",
previousState: .tablePlaceSheet
)
#expect(r.state == .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true)
XCTAssertEqual(r.state, .tablePlaceSheet)
XCTAssertEqual(r.isRemoveSheetNotice, true)
}
}
@Suite("ChartreadRow")
struct ChartreadRowTests {
final class ChartreadRowTests: XCTestCase {
@Test("Decodes row JSON")
func decode() throws {
func testDecode() throws {
let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [
@@ -155,13 +137,12 @@ struct ChartreadRowTests {
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A")
#expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51)
XCTAssertEqual(row.rowId, "A")
XCTAssertEqual(row.patchCount, 1)
XCTAssertEqual(row.patches[0].measured.lab?.l, 51)
}
@Test("Decodes a row carrying both XYZ and Lab arrays")
func decodeXYZAndLab() throws {
func testDecodeXYZAndLab() throws {
let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [
@@ -171,88 +152,78 @@ struct ChartreadRowTests {
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured
#expect(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.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
}
@Test("XYZColor/CIEXYZ encode as an unkeyed three-number array")
func xyzWireEncoding() throws {
func testXyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
#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 labWireEncoding() throws {
func testLabWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
#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 patchColorKeys() throws {
func testPatchColorKeys() throws {
let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any]
#expect(object?["XYZ"] as? [Double] == [10, 20, 30])
#expect(object?["Lab"] as? [Double] == [55, 1, -2])
#expect(object?["spectral"] == nil)
XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
XCTAssertNil(object?["spectral"])
}
}
@Suite("ColourMath")
struct ColourMathTests {
final class ColourMathTests: XCTestCase {
@Test("White XYZ to Lab")
func whiteLab() {
func testWhiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white)
#expect(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5)
XCTAssertTrue(abs(lab.l - 100) < 0.5)
XCTAssertTrue(abs(lab.a) < 0.5)
XCTAssertTrue(abs(lab.b) < 0.5)
}
@Test("Lab to sRGB roundtrip is clamped")
func labToSRGB() {
func testLabToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red)
#expect(rgb.r > 0.8)
#expect(rgb.g < 0.2)
#expect(rgb.b < 0.2)
XCTAssertTrue(rgb.r > 0.8)
XCTAssertTrue(rgb.g < 0.2)
XCTAssertTrue(rgb.b < 0.2)
}
@Test("Pad white returns DisplayRGB")
func padWhite() {
func testPadWhite() {
let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white)
#expect(rgb.r > 0.9)
#expect(rgb.g > 0.9)
#expect(rgb.b > 0.9)
XCTAssertTrue(rgb.r > 0.9)
XCTAssertTrue(rgb.g > 0.9)
XCTAssertTrue(rgb.b > 0.9)
}
@Test("Standard CIEDE2000 vector (Sharma)")
func ciede2000() {
func testCiede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
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 classify() {
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
func testClassify() {
XCTAssertEqual(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)
XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad)
}
}
@Suite("MeasurementArtefacts")
struct MeasurementArtefactTests {
final class MeasurementArtefactTests: XCTestCase {
private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory
@@ -261,8 +232,7 @@ struct MeasurementArtefactTests {
return url
}
@Test("Discovers passes in order")
func discovery() throws {
func testDiscovery() throws {
let cwd = try makeCwd()
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)
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 snapshotPromote() throws {
func testSnapshotPromote() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -283,16 +252,15 @@ struct MeasurementArtefactTests {
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass.lastPathComponent == "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path))
XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
#expect(promoted.lastPathComponent == "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path))
XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
}
@Test("Pass collisions handled")
func collision() throws {
func testCollision() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -302,28 +270,25 @@ struct MeasurementArtefactTests {
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass2.lastPathComponent == "target_pass2.ti3")
XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
}
}
@Suite("AverageArgs")
struct AverageArgsTests {
final class AverageArgsTests: XCTestCase {
@Test("Requires at least two pass files")
func passCount() {
func testPassCount() {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
)
#expect(throws: AverageArgError.self) {
_ = try AverageArgs.build(config: config)
XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
XCTAssertTrue(error is AverageArgError)
}
}
@Test("Output is last and inputs are relative")
func ordering() throws {
func testOrdering() throws {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
@@ -334,8 +299,8 @@ struct AverageArgsTests {
]
)
let args = try AverageArgs.build(config: config)
#expect(args.first == "-v")
#expect(args.last == "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
XCTAssertEqual(args.first, "-v")
XCTAssertEqual(args.last, "target.ti3")
XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
}
}
+163 -186
View File
@@ -1,116 +1,98 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
@Suite("ProfilingPreset")
struct ProfilingPresetTests {
final class ProfilingPresetTests: XCTestCase {
@Test("snake_case keys round-trip through Codable")
func roundTrip() throws {
func testRoundTrip() throws {
var p = PresetCatalog.highQualityCMYK
p.colprofInputViewingCond = "D50_2"
let data = try JSONEncoder().encode(p)
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
#expect(decoded == p)
XCTAssertEqual(decoded, p)
// Spot-check the wire format.
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["colour_space"] as? String == "cmyk")
#expect(obj["patch_count"] as? Int == 1500)
#expect(obj["total_ink_limit"] as? Int == 320)
#expect(obj["bit_depth"] as? Int == 16)
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2")
XCTAssertEqual(obj["colour_space"] as? String, "cmyk")
XCTAssertEqual(obj["patch_count"] as? Int, 1500)
XCTAssertEqual(obj["total_ink_limit"] as? Int, 320)
XCTAssertEqual(obj["bit_depth"] as? Int, 16)
XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2")
}
@Test("Unknown keys ignored; missing required field fails")
func schemaTolerance() throws {
func testSchemaTolerance() throws {
let json = """
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
"white_patches":1,"black_patches":1,"instrument":"i1",
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
""".data(using: .utf8)!
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
#expect(ok.id == "x")
XCTAssertEqual(ok.id, "x")
let missing = """
{"id":"x","name":"N","colour_space":"rgb"}
""".data(using: .utf8)!
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
}
XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
}
@Test("Validation rejects bad colour space / dpi / bit depth")
func validation() {
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()
}
func testValidation() {
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
}
}
@Suite("PresetCatalog")
struct PresetCatalogTests {
final class PresetCatalogTests: XCTestCase {
@Test("Four built-ins with the documented values")
func builtIns() {
#expect(PresetCatalog.builtIns.count == 4)
func testBuiltIns() {
XCTAssertEqual(PresetCatalog.builtIns.count, 4)
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
let std = byID["preset-std-rgb"]!
#expect(std.colourSpace == "rgb" && std.patchCount == 800
XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800
&& std.pageSize == "A4" && std.bitDepth == 8
&& std.dpi == 300 && std.colprofQuality == "m"
&& std.whitePatches == 4 && std.blackPatches == 4)
let hq = byID["preset-hq-cmyk"]!
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500
&& hq.pageSize == "A3" && hq.bitDepth == 16
&& hq.dpi == 300 && hq.colprofQuality == "h"
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
let draft = byID["preset-draft-rgb"]!
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400
XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400
&& draft.pageSize == "A4" && draft.bitDepth == 8
&& draft.dpi == 150 && draft.colprofQuality == "l")
let ultra = byID["preset-ultra-rgb"]!
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
&& ultra.ofpsHighQuality == true
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
for p in PresetCatalog.builtIns {
#expect(p.instrument == "i1")
#expect(p.colprofFwa == "D50")
#expect(p.randomSeed == 1)
#expect(p.noRandomize == false)
#expect(p.colprofAlgorithm == "l")
XCTAssertEqual(p.instrument, "i1")
XCTAssertEqual(p.colprofFwa, "D50")
XCTAssertEqual(p.randomSeed, 1)
XCTAssertEqual(p.noRandomize, false)
XCTAssertEqual(p.colprofAlgorithm, "l")
}
}
@Test("Custom presets overlay by id; built-ins are not deletable")
func overlay() {
func testOverlay() {
let custom = ProfilingPreset(
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
let all = PresetCatalog.all(custom: [custom])
#expect(all.count == 4)
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42)
#expect(PresetCatalog.isBuiltIn("preset-std-rgb"))
#expect(!PresetCatalog.isBuiltIn("custom-1"))
XCTAssertEqual(all.count, 4)
XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42)
XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb"))
XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1"))
}
}
@Suite("PresetStore")
struct PresetStoreTests {
final class PresetStoreTests: XCTestCase {
private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -119,58 +101,52 @@ struct PresetStoreTests {
return dir.appendingPathComponent("settings.json")
}
@Test("CRUD + export/import round-trip")
func crud() throws {
func testCrud() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
try store.saveCustom(p)
#expect(store.customs().count == 1)
#expect(store.all().count == 5)
XCTAssertEqual(store.customs().count, 1)
XCTAssertEqual(store.all().count, 5)
p.name = "Renamed"
try store.saveCustom(p)
#expect(store.customs().count == 1)
#expect(store.customs()[0].name == "Renamed")
XCTAssertEqual(store.customs().count, 1)
XCTAssertEqual(store.customs()[0].name, "Renamed")
let data = try store.export(p)
let imported = try store.import(data)
#expect(imported.name == "Renamed")
#expect(imported.dpi == 150)
XCTAssertEqual(imported.name, "Renamed")
XCTAssertEqual(imported.dpi, 150)
#expect(try store.deleteCustom(id: "custom-x"))
#expect(store.customs().isEmpty)
#expect(try !store.deleteCustom(id: "preset-std-rgb"))
XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
XCTAssertTrue(store.customs().isEmpty)
XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
}
@Test("Import rewrites a built-in id to a fresh custom id")
func importBuiltinCollision() throws {
func testImportBuiltinCollision() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.standardRGB)
let imported = try store.import(data)
#expect(imported.id.hasPrefix("custom-"))
#expect(!PresetCatalog.isBuiltIn(imported.id))
XCTAssertTrue(imported.id.hasPrefix("custom-"))
XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
}
@Test("Built-ins are immutable through saveCustom")
func builtInImmutable() throws {
func testBuiltInImmutable() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var shadowed = PresetCatalog.standardRGB
shadowed.name = "Hacked"
#expect(throws: PresetStore.PresetStoreError.self) {
try store.saveCustom(shadowed)
}
XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
}
}
@Suite("AppSettings preset migration")
struct PresetMigrationTests {
final class PresetMigrationTests: XCTestCase {
private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -179,8 +155,7 @@ struct PresetMigrationTests {
return dir.appendingPathComponent("settings.json")
}
@Test("Legacy M1 custom_presets migrate to typed schema")
func legacyMigration() throws {
func testLegacyMigration() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let legacy = """
@@ -194,20 +169,19 @@ struct PresetMigrationTests {
try legacy.write(to: url)
let settings = SettingsStore(fileURL: url).load()
#expect(settings.customPresets.count == 1)
XCTAssertEqual(settings.customPresets.count, 1)
let p = settings.customPresets[0]
#expect(p.name == "Old One")
#expect(p.id.hasPrefix("custom-0-"))
#expect(p.colourSpace == "cmyk")
#expect(p.patchCount == 900)
#expect(p.dpi == 150)
#expect(p.bitDepth == 16)
#expect(p.instrument == "p3")
#expect(p.pageSize == "A3")
XCTAssertEqual(p.name, "Old One")
XCTAssertTrue(p.id.hasPrefix("custom-0-"))
XCTAssertEqual(p.colourSpace, "cmyk")
XCTAssertEqual(p.patchCount, 900)
XCTAssertEqual(p.dpi, 150)
XCTAssertEqual(p.bitDepth, 16)
XCTAssertEqual(p.instrument, "p3")
XCTAssertEqual(p.pageSize, "A3")
}
@Test("Typed presets load and re-save as the typed schema")
func typedRoundTrip() throws {
func testTypedRoundTrip() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = SettingsStore(fileURL: url)
@@ -215,50 +189,45 @@ struct PresetMigrationTests {
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
try store.save(s)
let loaded = store.load()
#expect(loaded.customPresets.first?.patchCount == 700)
XCTAssertEqual(loaded.customPresets.first?.patchCount, 700)
}
@Test("Draft preset dpi=150 survives Codable + settings round-trip")
func draftDPI() throws {
func testDraftDPI() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.draftRGB)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["dpi"] as? Int == 150)
XCTAssertEqual(obj["dpi"] as? Int, 150)
let back = try store.import(data)
#expect(back.dpi == 150)
XCTAssertEqual(back.dpi, 150)
}
}
@Suite("Preset mapping")
struct PresetMappingTests {
@Test("Draft 150 DPI maps into PrinttargConfig")
func draftDpi() {
final class PresetMappingTests: XCTestCase {
func testDraftDpi() {
let cfg = PrinttargConfig(
preset: PresetCatalog.draftRGB,
basename: "t",
workingDirectory: nil,
calibrationFile: nil
)
#expect(cfg.dpi == 150)
#expect(cfg.layoutOrder == .deterministic)
XCTAssertEqual(cfg.dpi, 150)
XCTAssertEqual(cfg.layoutOrder, .deterministic)
}
@Test("Nil optional targen fields stay nil")
func optionalNil() {
func testOptionalNil() {
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(cfg.greySteps == nil)
#expect(cfg.singleChannelSteps == nil)
#expect(cfg.neutralSteps == nil)
#expect(cfg.totalInkLimit == nil)
#expect(cfg.darkEmphasis == nil)
#expect(cfg.devicePower == nil)
XCTAssertNil(cfg.greySteps)
XCTAssertNil(cfg.singleChannelSteps)
XCTAssertNil(cfg.neutralSteps)
XCTAssertNil(cfg.totalInkLimit)
XCTAssertNil(cfg.darkEmphasis)
XCTAssertNil(cfg.devicePower)
}
@Test("Custom page and FWA survive a config round-trip")
func roundTripConfigs() {
func testRoundTripConfigs() {
var preset = PresetCatalog.highQualityCMYK
preset.pageSize = "210x297"
preset.colprofFwa = "D50"
@@ -268,9 +237,9 @@ struct PresetMappingTests {
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
)
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
#expect(printtarg.pageSize == .custom)
#expect(printtarg.customPageWidth == 210)
#expect(colprof.fwa == "D50")
XCTAssertEqual(printtarg.pageSize, .custom)
XCTAssertEqual(printtarg.customPageWidth, 210)
XCTAssertEqual(colprof.fwa, "D50")
let back = ProfilingPreset(
id: preset.id,
name: preset.name,
@@ -281,15 +250,14 @@ struct PresetMappingTests {
calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration
)
#expect(back.dpi == preset.dpi)
#expect(back.colourSpace == "cmyk")
#expect(back.pageSize == "210x297")
#expect(back.colprofFwa == "D50")
#expect(back.greySteps == nil)
XCTAssertEqual(back.dpi, preset.dpi)
XCTAssertEqual(back.colourSpace, "cmyk")
XCTAssertEqual(back.pageSize, "210x297")
XCTAssertEqual(back.colprofFwa, "D50")
XCTAssertNil(back.greySteps)
}
@Test("Full preset round-trips through all three configs with every field asserted")
func fullRoundTrip() {
func testFullRoundTrip() {
let preset = ProfilingPreset(
id: "custom-full",
name: "Full",
@@ -328,21 +296,21 @@ struct PresetMappingTests {
)
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
#expect(targen.colourSpace == .cmyk)
#expect(targen.patchCount == 1500)
#expect(targen.whitePatches == 6)
#expect(targen.blackPatches == 8)
#expect(targen.greySteps == 9)
#expect(targen.singleChannelSteps == 7)
#expect(targen.neutralSteps == 4)
#expect(targen.neutralConcentration == 0.7)
#expect(targen.preconditioningProfile == "/tmp/pre.icm")
#expect(targen.ofpsHighQuality == true)
#expect(targen.ofpsAdaptation == 0.2)
#expect(targen.fullSpreadAlgorithm == .uniformRandom)
#expect(targen.totalInkLimit == 280)
#expect(targen.darkEmphasis == 1.3)
#expect(targen.devicePower == 1.2)
XCTAssertEqual(targen.colourSpace, .cmyk)
XCTAssertEqual(targen.patchCount, 1500)
XCTAssertEqual(targen.whitePatches, 6)
XCTAssertEqual(targen.blackPatches, 8)
XCTAssertEqual(targen.greySteps, 9)
XCTAssertEqual(targen.singleChannelSteps, 7)
XCTAssertEqual(targen.neutralSteps, 4)
XCTAssertEqual(targen.neutralConcentration, 0.7)
XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm")
XCTAssertEqual(targen.ofpsHighQuality, true)
XCTAssertEqual(targen.ofpsAdaptation, 0.2)
XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom)
XCTAssertEqual(targen.totalInkLimit, 280)
XCTAssertEqual(targen.darkEmphasis, 1.3)
XCTAssertEqual(targen.devicePower, 1.2)
let printtarg = PrinttargConfig(
preset: preset,
@@ -350,25 +318,25 @@ struct PresetMappingTests {
workingDirectory: nil,
calibrationFile: preset.calibrationFile
)
#expect(printtarg.instrument == .p3)
#expect(printtarg.pageSize == .custom)
#expect(printtarg.customPageWidth == 250)
#expect(printtarg.customPageHeight == 300)
#expect(printtarg.bitDepth == .sixteen)
#expect(printtarg.dpi == 360)
#expect(printtarg.layoutOrder == .customSeed)
#expect(printtarg.customSeed == 42)
#expect(printtarg.calibrationFile == "/tmp/a.cal")
XCTAssertEqual(printtarg.instrument, .p3)
XCTAssertEqual(printtarg.pageSize, .custom)
XCTAssertEqual(printtarg.customPageWidth, 250)
XCTAssertEqual(printtarg.customPageHeight, 300)
XCTAssertEqual(printtarg.bitDepth, .sixteen)
XCTAssertEqual(printtarg.dpi, 360)
XCTAssertEqual(printtarg.layoutOrder, .customSeed)
XCTAssertEqual(printtarg.customSeed, 42)
XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal")
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
#expect(colprof.algorithm == "x")
#expect(colprof.quality == "u")
#expect(colprof.intent == "p")
#expect(colprof.fwa == "D65")
#expect(colprof.illuminant == "D65")
#expect(colprof.observer == "1931_2")
#expect(colprof.inputViewingCond == "D50_2")
#expect(colprof.outputViewingCond == "D65_2")
XCTAssertEqual(colprof.algorithm, "x")
XCTAssertEqual(colprof.quality, "u")
XCTAssertEqual(colprof.intent, "p")
XCTAssertEqual(colprof.fwa, "D65")
XCTAssertEqual(colprof.illuminant, "D65")
XCTAssertEqual(colprof.observer, "1931_2")
XCTAssertEqual(colprof.inputViewingCond, "D50_2")
XCTAssertEqual(colprof.outputViewingCond, "D65_2")
let back = ProfilingPreset(
id: preset.id,
@@ -380,11 +348,12 @@ struct PresetMappingTests {
calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration
)
#expect(back == preset)
XCTAssertEqual(back, preset)
}
@Test("Every full-spread algorithm round-trips", arguments: [
("ofps", FullSpreadAlgorithm.ofps),
func testFullSpreadAlgorithms() {
let cases: [(String, FullSpreadAlgorithm)] = [
("ofps", .ofps),
("t", .target),
("r", .random),
("R", .uniformRandom),
@@ -392,16 +361,16 @@ struct PresetMappingTests {
("Q", .uniformQuasiRandom),
("i", .invertedQuasiRandom),
("I", .invertedUniformQuasiRandom)
])
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) {
]
for (value, expected) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.fullSpreadAlgorithm = value
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
if expected == .ofps {
// ofps is the default no flag emitted, stored value is nil.
#expect(cfg.fullSpreadAlgorithm == nil)
XCTAssertNil(cfg.fullSpreadAlgorithm)
} else {
#expect(cfg.fullSpreadAlgorithm == expected)
XCTAssertEqual(cfg.fullSpreadAlgorithm, expected)
}
let back = ProfilingPreset(
id: "x", name: "n", description: "",
@@ -414,30 +383,31 @@ struct PresetMappingTests {
calibrationFile: nil,
applyCalibration: nil
)
#expect(back.fullSpreadAlgorithm == value)
XCTAssertEqual(back.fullSpreadAlgorithm, value)
}
}
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil")
func ofpsHighQualityFalse() {
func testOfpsHighQualityFalse() {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.ofpsHighQuality = false
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(cfg.ofpsHighQuality == false)
XCTAssertEqual(cfg.ofpsHighQuality, false)
preset.ofpsHighQuality = nil
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(nilCfg.ofpsHighQuality == nil)
XCTAssertNil(nilCfg.ofpsHighQuality)
}
@Test("noRandomize/seed layout mapping rules", arguments: [
(true, nil, LayoutOrder.raster, 1),
func testLayoutMapping() {
let cases: [(Bool?, Int?, LayoutOrder, Int)] = [
(true, nil, .raster, 1),
(true, 7, .raster, 7),
(false, nil, .deterministic, 1),
(false, 1, .deterministic, 1),
(nil, 1, .deterministic, 1),
(false, 5, .customSeed, 5)
] as [(Bool?, Int?, LayoutOrder, Int)])
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) {
]
for (noRandomize, seed, layout, expectedSeed) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.noRandomize = noRandomize
preset.randomSeed = seed
@@ -445,31 +415,35 @@ struct PresetMappingTests {
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
#expect(cfg.layoutOrder == layout)
#expect(cfg.customSeed == expectedSeed)
XCTAssertEqual(cfg.layoutOrder, layout)
XCTAssertEqual(cfg.customSeed, expectedSeed)
}
}
@Test("Custom page fallback matrix", arguments: [
("250x300", PageSize.custom, 250.0, 300.0),
func testCustomPageFallback() {
let cases: [(String, PageSize, Double, Double)] = [
("250x300", .custom, 250.0, 300.0),
("50x50", .custom, 50.0, 50.0),
("foo", .a4, 210.0, 297.0),
("30x40", .a4, 210.0, 297.0),
("210x", .a4, 210.0, 297.0)
] as [(String, PageSize, Double, Double)])
func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) {
]
for (raw, page, w, h) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.pageSize = raw
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
#expect(cfg.pageSize == page)
#expect(cfg.customPageWidth == w)
#expect(cfg.customPageHeight == h)
XCTAssertEqual(cfg.pageSize, page)
XCTAssertEqual(cfg.customPageWidth, w)
XCTAssertEqual(cfg.customPageHeight, h)
}
}
@Test("FWA preset value → selection matrix", arguments: [
(nil, ColprofFwaSelection.none),
func testFwaToSelection() {
let cases: [(String?, ColprofFwaSelection)] = [
(nil, .none),
("none", .none),
("NONE", .none),
("", .empty),
@@ -478,19 +452,22 @@ struct PresetMappingTests {
("D65", .D65),
("d65", .D65),
("/tmp/fwa.sp", .custom)
] as [(String?, ColprofFwaSelection)])
func fwaToSelection(raw: String?, expected: ColprofFwaSelection) {
#expect(ColprofFwaSelection(presetValue: raw) == expected)
]
for (raw, expected) in cases {
XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected)
}
}
@Test("FWA selection → preset value matrix", arguments: [
(ColprofFwaSelection.none, nil),
func testFwaToPresetValue() {
let cases: [(ColprofFwaSelection, String?)] = [
(.none, nil),
(.empty, ""),
(.D50, "D50"),
(.D65, "D65"),
(.custom, "/tmp/fwa.sp")
] as [(ColprofFwaSelection, String?)])
func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) {
#expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected)
]
for (selection, expected) in cases {
XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected)
}
}
}
@@ -1,21 +1,19 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #82 preset application through the live view models, under an
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
@Suite("PresetViewModelMapping")
@MainActor
struct PresetViewModelMappingTests {
final class PresetViewModelMappingTests: XCTestCase {
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
let env = try TestAppEnvironment.make()
return (env, TargetWorkflowViewModel(environment: env.environment))
}
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path")
func nilFwaClearsCustomPath() throws {
func testNilFwaClearsCustomPath() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -24,18 +22,17 @@ struct PresetViewModelMappingTests {
colprofFwa: "/tmp/fwa.sp"
)
vm.applyPreset(customPreset)
#expect(vm.profile.fwaSelection == .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp")
XCTAssertEqual(vm.profile.fwaSelection, .custom)
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp")
customPreset.colprofFwa = nil
vm.applyPreset(customPreset)
#expect(vm.profile.fwaSelection == .none)
#expect(vm.profile.fwaCustomPath == "")
#expect(vm.profile.fwaValue == nil)
XCTAssertEqual(vm.profile.fwaSelection, .none)
XCTAssertEqual(vm.profile.fwaCustomPath, "")
XCTAssertNil(vm.profile.fwaValue)
}
@Test("Custom FWA preset path survives the round-trip to colprof_fwa")
func customFwaRoundTrip() throws {
func testCustomFwaRoundTrip() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -44,13 +41,12 @@ struct PresetViewModelMappingTests {
colprofFwa: "/tmp/other.sp"
)
vm.applyPreset(preset)
#expect(vm.profile.fwaSelection == .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp")
#expect(vm.profile.fwaValue == "/tmp/other.sp")
XCTAssertEqual(vm.profile.fwaSelection, .custom)
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp")
XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp")
}
@Test("Preset calibration reaches Stage 2 instead of stale live state")
func presetCalibrationReachesStage2() throws {
func testPresetCalibrationReachesStage2() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -65,13 +61,12 @@ struct PresetViewModelMappingTests {
)
vm.applyPreset(preset)
#expect(vm.profile.applyCalibration)
#expect(vm.profile.calibrationFile == "/tmp/preset.cal")
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal")
XCTAssertTrue(vm.profile.applyCalibration)
XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal")
XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal")
}
@Test("Preset with calibration disabled clears Stage 2 calibration")
func disabledCalibrationClearsStage2() throws {
func testDisabledCalibrationClearsStage2() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -85,12 +80,11 @@ struct PresetViewModelMappingTests {
)
vm.applyPreset(preset)
#expect(!vm.profile.applyCalibration)
#expect(vm.buildPrinttargConfig().calibrationFile == nil)
XCTAssertFalse(vm.profile.applyCalibration)
XCTAssertNil(vm.buildPrinttargConfig().calibrationFile)
}
@Test("Preset Stage 1/2 form fields apply to the live form")
func formFieldsApply() throws {
func testFormFieldsApply() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -106,22 +100,22 @@ struct PresetViewModelMappingTests {
)
vm.applyPreset(preset)
#expect(vm.colourSpace == .cmyk)
#expect(vm.effectivePatchCount == 1500)
#expect(vm.whitePatches == 6)
#expect(vm.blackPatches == 8)
#expect(vm.greyStepsEnabled && vm.greySteps == 9)
#expect(vm.algorithm == .random)
#expect(vm.tiffDpi == 150)
#expect(vm.pageSize == .custom)
#expect(vm.customPageW == 250 && vm.customPageH == 300)
#expect(vm.selectedPresetID == "c-form")
XCTAssertEqual(vm.colourSpace, .cmyk)
XCTAssertEqual(vm.effectivePatchCount, 1500)
XCTAssertEqual(vm.whitePatches, 6)
XCTAssertEqual(vm.blackPatches, 8)
XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9)
XCTAssertEqual(vm.algorithm, .random)
XCTAssertEqual(vm.tiffDpi, 150)
XCTAssertEqual(vm.pageSize, .custom)
XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300)
XCTAssertEqual(vm.selectedPresetID, "c-form")
// Disabled advanced controls stay nil in the snapshot, not
// numeric sentinels.
preset.greySteps = nil
vm.applyPreset(preset)
#expect(!vm.greyStepsEnabled)
#expect(vm.buildTargenConfig().greySteps == nil)
XCTAssertFalse(vm.greyStepsEnabled)
XCTAssertNil(vm.buildTargenConfig().greySteps)
}
}
+13 -17
View File
@@ -1,13 +1,12 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on.
@Suite("PrintPanelStub")
struct PrintPanelStubTests {
final class PrintPanelStubTests: XCTestCase {
private func withEnv(
_ vars: [String: String?],
@@ -28,19 +27,17 @@ struct PrintPanelStubTests {
try body()
}
@Test("Cancel returns nil — not an error")
func cancelIsNil() throws {
func testCancelIsNil() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel",
]) {
#expect(UITestHooks.printPanelStubbed)
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
XCTAssertTrue(UITestHooks.printPanelStubbed)
XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
}
}
@Test("OK returns captured options + selected printer")
func okResult() throws {
func testOkResult() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
@@ -48,15 +45,14 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
#expect(result?.selectedPrinter == "Other_Queue")
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
#expect(result?.options.mediaType == "Photo")
#expect(result?.options.ppdUncorrectedPassthrough == true)
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
XCTAssertEqual(result?.options.mediaType, "Photo")
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
}
}
@Test("OK defaults selected printer to the opened queue")
func okDefaultsPrinter() throws {
func testOkDefaultsPrinter() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
@@ -64,8 +60,8 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
#expect(result?.selectedPrinter == "My_Queue")
#expect(result?.options.cupsOptions == nil)
XCTAssertEqual(result?.selectedPrinter, "My_Queue")
XCTAssertNil(result?.options.cupsOptions)
}
}
}
+13 -21
View File
@@ -1,24 +1,21 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("PrintcalArgs")
struct PrintcalArgsTests {
final class PrintcalArgsTests: XCTestCase {
private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
@Test("Default printcal argv")
func defaults() throws {
func testDefaults() throws {
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
outputURL: tmp
)
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 allOptions() throws {
func testAllOptions() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
@@ -32,7 +29,7 @@ struct PrintcalArgsTests {
]
)
let args = try PrintcalArgs.build(config: config)
#expect(args == [
XCTAssertEqual(args, [
"-v", "-e",
"-I", "-z",
"-a", "/tmp/old.cal",
@@ -44,39 +41,34 @@ struct PrintcalArgsTests {
])
}
@Test("Whitespace-only previous calibration path emits no -a")
func whitespacePreviousCal() throws {
func testWhitespacePreviousCal() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " \n\t "
)
let args = try PrintcalArgs.build(config: config)
#expect(!args.contains("-a"))
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
XCTAssertFalse(args.contains("-a"))
XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
}
@Test("Previous calibration path is trimmed before emission")
func previousCalTrimmed() throws {
func testPreviousCalTrimmed() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " /tmp/old.cal "
)
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 rejectsBadChannelLimit() {
func testRejectsBadChannelLimit() {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
)
#expect(throws: (any Error).self) {
_ = try PrintcalArgs.build(config: config)
}
XCTAssertThrowsError(try PrintcalArgs.build(config: config))
}
}
+99 -130
View File
@@ -1,9 +1,8 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@Suite("PrinttargArgs")
struct PrinttargArgsTests {
final class PrinttargArgsTests: XCTestCase {
private func config(
instrument: PrintInstrument = .i1,
@@ -28,136 +27,121 @@ struct PrinttargArgsTests {
)
}
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300")
func baseline() throws {
func testBaseline() throws {
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"])
}
@Test("Default layout is deterministic -R 1, never bare")
func deterministicDefault() throws {
func testDeterministicDefault() throws {
let args = try PrinttargArgs.build(config: config())
#expect(args.contains("-R"))
#expect(!args.contains("-r"))
#expect(args[args.firstIndex(of: "-R")! + 1] == "1")
XCTAssertTrue(args.contains("-R"))
XCTAssertFalse(args.contains("-r"))
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1")
}
@Test("Custom seed -R N; seed < 1 throws")
func customSeed() throws {
func testCustomSeed() throws {
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
#expect(args[args.firstIndex(of: "-R")! + 1] == "42")
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42")
XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in
XCTAssertTrue(error is PrinttargArgError)
}
}
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)")
func raster() throws {
func testRaster() throws {
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
#expect(args.contains("-r"))
#expect(!args.contains("-R"))
XCTAssertTrue(args.contains("-r"))
XCTAssertFalse(args.contains("-R"))
}
@Test("Label: -d emits the resolved string, not a colour space")
func label() throws {
func testLabel() throws {
let args = try PrinttargArgs.build(
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
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 bitDepthAndDPI() throws {
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
func testBitDepthAndDPI() throws {
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
.contains("-T"))
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
.contains("-t"))
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(dpi: 71))
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in
XCTAssertTrue(error is PrinttargArgError)
}
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(dpi: 601))
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in
XCTAssertTrue(error is PrinttargArgError)
}
}
@Test("All instruments emit their Argyll code")
func instruments() throws {
func testInstruments() throws {
let expected: [(PrintInstrument, String)] = [
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
]
for (inst, code) in expected {
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 pageSizes() throws {
func testPageSizes() throws {
for size in PageSize.allCases where size != .custom {
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(
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 customPageTooSmall() {
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
func testCustomPageTooSmall() {
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in
XCTAssertTrue(error is PrinttargArgError)
}
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in
XCTAssertTrue(error is PrinttargArgError)
}
}
@Test("Calibration: -K applies, -I embeds")
func calibrationFlags() throws {
func testCalibrationFlags() throws {
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))
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal")
#expect(!i.contains("-K"))
XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal")
XCTAssertFalse(i.contains("-K"))
}
@Test("CAL_ basename never gets -K or -I")
func calProtection() throws {
func testCalProtection() throws {
let args = try PrinttargArgs.build(
config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
#expect(!args.contains("-K"))
#expect(!args.contains("-I"))
XCTAssertFalse(args.contains("-K"))
XCTAssertFalse(args.contains("-I"))
}
@Test("Whitespace-only label emits no -d; whitespace-only calibration emits no -K/-I")
func whitespaceOptions() throws {
func testWhitespaceOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " \n ", calFile: " \t "))
#expect(!args.contains("-d"))
#expect(!args.contains("-K"))
#expect(!args.contains("-I"))
XCTAssertFalse(args.contains("-d"))
XCTAssertFalse(args.contains("-K"))
XCTAssertFalse(args.contains("-I"))
}
@Test("Label and calibration values are trimmed before emission")
func trimmedOptions() throws {
func testTrimmedOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " My Label ", calFile: " /tmp/a.cal "))
#expect(args[args.firstIndex(of: "-d")! + 1] == "My Label")
#expect(args[args.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
XCTAssertEqual(args[args.firstIndex(of: "-d")! + 1], "My Label")
XCTAssertEqual(args[args.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
}
@Test("Unsafe basename throws")
func unsafeBasename() {
#expect(throws: PathSecurity.Error.self) {
try PrinttargArgs.build(config: config(basename: "../x"))
func testUnsafeBasename() {
XCTAssertThrowsError(try PrinttargArgs.build(config: config(basename: "../x"))) { error in
XCTAssertTrue(error is PathSecurity.Error)
}
}
}
@Suite("PrinttargLabel")
struct PrinttargLabelTests {
final class PrinttargLabelTests: XCTestCase {
private var fixedDate: Date {
var comps = DateComponents()
@@ -166,37 +150,33 @@ struct PrinttargLabelTests {
return Calendar(identifier: .gregorian).date(from: comps)!
}
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM")
func automatic() {
func testAutomatic() {
let label = PrinttargLabel.automatic(
basename: "tgt",
metadata: TargetLabelMetadata(
printer: "Epson", inkSet: "CMYK",
driverPaper: "Photo", actualPaper: "Matte"),
date: fixedDate, timeZone: .current)
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
}
@Test("Missing metadata becomes Unspecified")
func unspecified() {
func testUnspecified() {
let label = PrinttargLabel.automatic(
basename: "tgt", metadata: TargetLabelMetadata(),
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 manualWins() {
func testManualWins() {
let resolved = PrinttargLabel.resolved(
customLabel: " My Label ", basename: "tgt",
metadata: TargetLabelMetadata(), date: fixedDate)
#expect(resolved == "My Label")
XCTAssertEqual(resolved, "My Label")
}
}
@Suite("PrinttargManifest")
struct PrinttargManifestTests {
final class PrinttargManifestTests: XCTestCase {
private let prettySingle = """
Some log line
@@ -225,66 +205,58 @@ struct PrinttargManifestTests {
}
"""
@Test("Decodes a single-page pretty manifest amid log noise")
func singlePage() throws {
func testSinglePage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
#expect(m.event == "manifest")
#expect(m.pages.count == 1)
#expect(m.pages[0].filename == "target.tif")
#expect(m.pages[0].patches == 800)
XCTAssertEqual(m.event, "manifest")
XCTAssertEqual(m.pages.count, 1)
XCTAssertEqual(m.pages[0].filename, "target.tif")
XCTAssertEqual(m.pages[0].patches, 800)
}
@Test("Multi-page manifest preserves order")
func multiPage() throws {
func testMultiPage() throws {
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 noJSON() {
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
func testNoJSON() {
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in
XCTAssertTrue(error is ManifestError)
}
}
@Test("Wrong event → wrongEvent")
func wrongEvent() {
func testWrongEvent() {
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: stdout)
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
XCTAssertTrue(error is ManifestError)
}
}
@Test("ROW_COLORS_JSON line is never treated as the manifest")
func rowColorsNotManifest() {
func testRowColorsNotManifest() {
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
// Extraction only starts at a '{' that begins a trimmed line,
// so the ROW_COLORS_JSON line is skipped entirely.
let m = try? PrinttargManifestExtractor.manifest(from: stdout)
#expect(m != nil)
#expect(m?.event == "manifest")
XCTAssertNotNil(m)
XCTAssertEqual(m?.event, "manifest")
}
@Test("Braces inside a quoted filename do not corrupt the scan")
func bracesInFilename() throws {
func testBracesInFilename() 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 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 unsafeFilenames() {
func testUnsafeFilenames() {
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}"
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: stdout)
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
XCTAssertTrue(error is ManifestError)
}
}
}
}
@Suite("ArgyllRunner Printtarg")
struct ArgyllRunnerPrinttargTests {
final class ArgyllRunnerPrinttargTests: XCTestCase {
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -336,8 +308,7 @@ struct ArgyllRunnerPrinttargTests {
try Data(bytes).write(to: url)
}
@Test("Successful printtarg emits .ti2 + manifest + PNG previews")
func success() async throws {
func testSuccess() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
@@ -356,18 +327,17 @@ struct ArgyllRunnerPrinttargTests {
processManager: ProcessManager(), binaryResolver: resolver)
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
let result = try await runner.runPrinttarg(config: config)
#expect(result.ti2URL.lastPathComponent == "pt.ti2")
#expect(result.manifest.pages.count == 1)
#expect(result.pages.count == 1)
XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2")
XCTAssertEqual(result.manifest.pages.count, 1)
XCTAssertEqual(result.pages.count, 1)
let png = result.pages[0].previewPNG
#expect(png != nil)
XCTAssertNotNil(png)
if let png {
#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]))
}
}
@Test("Non-zero exit throws toolFailed and stays on stage")
func failure() async throws {
func testFailure() async throws {
let dir = try makeFixture("""
#!/bin/sh
echo "oops" >&2
@@ -377,15 +347,16 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "printtarg", code: 3, logs: ["oops"])) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printtarg", code: 3, logs: ["oops"]))
}
}
@Test("Exit 0 without manifest → malformedManifest")
func noManifest() async throws {
func testNoManifest() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
@@ -398,14 +369,13 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
}
}
@Test("Exit 0 without .ti2 → missingArtefact")
func noTi2() async throws {
func testNoTi2() async throws {
let dir = try makeFixture("""
#!/bin/sh
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
@@ -415,14 +385,13 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
}
}
@Test("Deterministic config produces byte-identical .ti2")
func determinism() async throws {
func testDeterminism() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
@@ -442,6 +411,6 @@ struct ArgyllRunnerPrinttargTests {
config: PrinttargConfig(basename: "b", workingDirectory: dir))
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
#expect(d1 == d2)
XCTAssertEqual(d1, d2)
}
}
+134 -117
View File
@@ -1,11 +1,11 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
/// scripts written to a temp dir no resource bundling required.
@Suite("ProcessManager", .serialized)
struct ProcessManagerTests {
/// XCTest executes test methods serially by default.
final class ProcessManagerTests: XCTestCase {
// MARK: - Fixture plumbing
@@ -43,7 +43,7 @@ struct ProcessManagerTests {
if box.finish() { cont.resume(returning: box.events) }
}
Task {
try? await Task.sleep(for: .seconds(timeout))
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
if box.finish() { cont.resume(returning: box.events) }
}
}
@@ -84,7 +84,7 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if exitCount(in: box) > 0 { return true }
try? await Task.sleep(for: .milliseconds(10))
try? await Task.sleep(nanoseconds: 10_000_000)
}
return false
}
@@ -93,7 +93,7 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true }
try? await Task.sleep(for: .milliseconds(10))
try? await Task.sleep(nanoseconds: 10_000_000)
}
return false
}
@@ -106,14 +106,14 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await manager.isRunning(id) { return true }
try? await Task.sleep(for: .milliseconds(10))
try? await Task.sleep(nanoseconds: 10_000_000)
}
return false
}
// MARK: - Tests
@Test func streamsStdoutAndEmitsExit() async throws {
func testStreamsStdoutAndEmitsExit() async throws {
let pm = ProcessManager()
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
async let events = collect(pm, id: "t1")
@@ -122,21 +122,21 @@ struct ProcessManagerTests {
let lines = evs.compactMap { e -> String? in
if case .stdout(_, let l) = e { return l }; return nil
}
#expect(lines == ["hello", "world"])
#expect(evs.contains(.exit(id: "t1", code: 0)))
XCTAssertEqual(lines, ["hello", "world"])
XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0)))
}
@Test func routesStderrSeparately() async throws {
func testRoutesStderrSeparately() async throws {
let pm = ProcessManager()
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
async let evs = collect(pm, id: "t2")
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
let events = await evs
#expect(events.contains(.stdout(id: "t2", line: "out")))
#expect(events.contains(.stderr(id: "t2", line: "oops")))
XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out")))
XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops")))
}
@Test func stripsRowColorsJSONPrefix() async throws {
func testStripsRowColorsJSONPrefix() async throws {
let pm = ProcessManager()
let bin = try script(
"rows.sh",
@@ -149,21 +149,22 @@ struct ProcessManagerTests {
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
#expect(rows == ["{\"row\":1}"])
#expect(events.contains(.stdout(id: "t3", line: "plain")))
XCTAssertEqual(rows, ["{\"row\":1}"])
XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain")))
// Prefixed lines must not leak into stdout.
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
XCTAssertFalse(events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
}
@Test func unterminatedTailFlushesOnExit() async throws {
func testUnterminatedTailFlushesOnExit() async throws {
let pm = ProcessManager()
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
async let evs = collect(pm, id: "t4")
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
let t4SawTail = await evs.contains(.stdout(id: "t4", line: "no-newline"))
XCTAssertTrue(t4SawTail)
}
@Test func stdinRoundTrip() async throws {
func testStdinRoundTrip() async throws {
let pm = ProcessManager()
// Read two lines then exit naturally a killed sh would lose its
// buffered stdio output, which is exactly the chartread pattern.
@@ -176,21 +177,23 @@ struct ProcessManagerTests {
try await pm.sendStdin(id: "t5", text: " \n")
try await pm.sendStdin(id: "t5", text: "d\n")
let events = await evs
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: ")))
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d")))
}
@Test func duplicateIDRejected() async throws {
func testDuplicateIDRejected() async throws {
let pm = ProcessManager()
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
await #expect(throws: ProcessError.duplicateID("t6")) {
await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
} errorHandler: { error in
XCTAssertEqual(error, .duplicateID("t6"))
}
await pm.kill(id: "t6")
}
@Test func killEmitsExitAndClosesStdin() async throws {
func testKillEmitsExitAndClosesStdin() async throws {
let pm = ProcessManager()
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
async let evs = collect(pm, id: "t7")
@@ -199,49 +202,51 @@ struct ProcessManagerTests {
let events = await evs
// exit emitted exactly once
let exits = events.filter { if case .exit = $0 { return true }; return false }
#expect(exits.count == 1)
await #expect(throws: ProcessError.unknownID("t7")) {
XCTAssertEqual(exits.count, 1)
await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.sendStdin(id: "t7", text: "d\n")
} errorHandler: { error in
XCTAssertEqual(error, .unknownID("t7"))
}
}
@Test func killAllCountsSignaled() async throws {
func testKillAllCountsSignaled() async throws {
let pm = ProcessManager()
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
let count = await pm.killAll()
#expect(count == 2)
XCTAssertEqual(count, 2)
}
@Test func capturedRunReturnsBothStreams() async throws {
func testCapturedRunReturnsBothStreams() async throws {
let pm = ProcessManager()
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
#expect(result.stdout.contains("out-data"))
#expect(result.stderr.contains("err-data"))
#expect(result.exitCode == 3)
XCTAssertTrue(result.stdout.contains("out-data"))
XCTAssertTrue(result.stderr.contains("err-data"))
XCTAssertEqual(result.exitCode, 3)
}
@Test func capturedRunFastExit() async throws {
func testCapturedRunFastExit() async throws {
let pm = ProcessManager()
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
#expect(result.exitCode == 7)
#expect(result.stdout == "")
#expect(result.stderr == "")
XCTAssertEqual(result.exitCode, 7)
XCTAssertEqual(result.stdout, "")
XCTAssertEqual(result.stderr, "")
}
@Test func capturedRunStderrOnly() async throws {
func testCapturedRunStderrOnly() async throws {
let pm = ProcessManager()
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
#expect(result.exitCode == 1)
#expect(result.stdout == "")
#expect(result.stderr.contains("mock lp failure"))
XCTAssertEqual(result.exitCode, 1)
XCTAssertEqual(result.stdout, "")
XCTAssertTrue(result.stderr.contains("mock lp failure"))
}
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws {
let pm = ProcessManager()
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
let bin = try script(
@@ -249,26 +254,29 @@ struct ProcessManagerTests {
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
)
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
#expect(result.stdout.contains("out-4999"))
#expect(result.stderr.contains("err-4999"))
XCTAssertTrue(result.stdout.contains("out-4999"))
XCTAssertTrue(result.stderr.contains("err-4999"))
}
@Test func argyllEnvVarIsSet() async throws {
func testArgyllEnvVarIsSet() async throws {
let pm = ProcessManager()
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
async let evs = collect(pm, id: "t10")
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
let t10SawEnv = await evs.contains(.stdout(id: "t10", line: "ANI=1"))
XCTAssertTrue(t10SawEnv)
}
@Test func unknownIDStdinThrows() async throws {
func testUnknownIDStdinThrows() async throws {
let pm = ProcessManager()
await #expect(throws: ProcessError.unknownID("nope")) {
await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.sendStdin(id: "nope", text: "d\n")
} errorHandler: { error in
XCTAssertEqual(error, .unknownID("nope"))
}
}
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
func testExplicitPartialFlushEmitsRowColorsJSON() async throws {
let pm = ProcessManager()
let marker = Self.fixtureDir
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
@@ -279,7 +287,8 @@ struct ProcessManagerTests {
let box = Box()
let observer = observe(pm, id: "t11", into: box)
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
#expect(await waitForFile(marker))
let markerReady = await waitForFile(marker)
XCTAssertTrue(markerReady)
// Retry the flush so the pipe-ingest task can win the actor race
// on a loaded host; the first successful flush emits the row.
var flushed = false
@@ -289,24 +298,25 @@ struct ProcessManagerTests {
flushed = true
break
}
try await Task.sleep(for: .milliseconds(20))
try await Task.sleep(nanoseconds: 20_000_000)
}
#expect(flushed)
XCTAssertTrue(flushed)
await pm.kill(id: "t11")
#expect(await waitForExit(in: box))
let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
observer.cancel()
let events = box.events
let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
#expect(rows == ["{\"row\":9}"])
XCTAssertEqual(rows, ["{\"row\":9}"])
// Prefixed tails must not leak into stdout, even via finalize.
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
#expect(exitCount(in: box) == 1)
XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
XCTAssertEqual(exitCount(in: box), 1)
}
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
func testUnterminatedRowTailFinalizesAsJSONRow() async throws {
let pm = ProcessManager()
let bin = try script(
"row-tail.sh",
@@ -315,68 +325,70 @@ struct ProcessManagerTests {
let box = Box()
let observer = observe(pm, id: "t12", into: box)
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
#expect(await waitForExit(in: box))
let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
observer.cancel()
let events = box.events
let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
#expect(rows == ["{\"row\":42}"])
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
XCTAssertEqual(rows, ["{\"row\":42}"])
XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
let rowIndex = events.firstIndex {
if case .jsonRow = $0 { return true }; return false
}
let exitIndexes = events.indices.filter {
if case .exit = events[$0] { return true }; return false
}
#expect(exitIndexes.count == 1)
XCTAssertEqual(exitIndexes.count, 1)
if let rowIndex, let exitIndex = exitIndexes.first {
#expect(rowIndex < exitIndex)
XCTAssertTrue(rowIndex < exitIndex)
} else {
Issue.record("expected a jsonRow before the exit event")
XCTFail("expected a jsonRow before the exit event")
}
}
@Test func fastStreamingExitEmitsExactlyOneExit() async throws {
func testFastStreamingExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager()
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
let box = Box()
let observer = observe(pm, id: "t13", into: box)
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
#expect(await waitForExit(in: box))
let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
// The grace window must outlast the 2 s finalize watchdog so a
// duplicate emission from it would be observed.
try await Task.sleep(for: .milliseconds(2500))
try await Task.sleep(nanoseconds: 2_500_000_000)
observer.cancel()
#expect(box.events == [.exit(id: "t13", code: 0)])
XCTAssertEqual(box.events, [.exit(id: "t13", code: 0)])
}
@Test func fastCapturedExitEmitsExactlyOneExit() async throws {
func testFastCapturedExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager()
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
let box = Box()
let observer = observe(pm, id: "t14", into: box)
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
#expect(result.exitCode == 7)
XCTAssertEqual(result.exitCode, 7)
// Both the termination handler and the waitUntilExit watchdog
// resume the same box; give the slower path time to fire.
try await Task.sleep(for: .milliseconds(500))
try await Task.sleep(nanoseconds: 500_000_000)
observer.cancel()
#expect(box.events == [.exit(id: "t14", code: 7)])
XCTAssertEqual(box.events, [.exit(id: "t14", code: 7)])
}
@Test func capturedRunSetsArgyllNotInteractive() async throws {
func testCapturedRunSetsArgyllNotInteractive() async throws {
let pm = ProcessManager()
let bin = try script(
"cap-env.sh",
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
)
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
#expect(result.stdout == "ANI=1\n")
XCTAssertEqual(result.stdout, "ANI=1\n")
}
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
func testKillAllTerminatesStreamingAndCapturedChildren() async throws {
let pm = ProcessManager()
let marker = Self.fixtureDir
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
@@ -390,83 +402,88 @@ struct ProcessManagerTests {
let capTask = Task {
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
}
#expect(await waitForFile(marker))
#expect(await waitForRunning(pm, id: "t16"))
#expect(await waitForRunning(pm, id: "t17"))
#expect(await pm.killAll() == 2)
let markerReady = await waitForFile(marker)
XCTAssertTrue(markerReady)
let t16Running = await waitForRunning(pm, id: "t16")
XCTAssertTrue(t16Running)
let t17Running = await waitForRunning(pm, id: "t17")
XCTAssertTrue(t17Running)
let killed = await pm.killAll()
XCTAssertEqual(killed, 2)
_ = try await capTask.value
#expect(await waitForExit(in: streamBox))
#expect(await waitForExit(in: capBox))
let streamExit = await waitForExit(in: streamBox)
XCTAssertTrue(streamExit)
let capExit = await waitForExit(in: capBox)
XCTAssertTrue(capExit)
// Grace window outlasts the streaming finalize watchdog.
try await Task.sleep(for: .milliseconds(2500))
try await Task.sleep(nanoseconds: 2_500_000_000)
streamObserver.cancel()
capObserver.cancel()
#expect(!(await pm.isRunning("t16")))
#expect(!(await pm.isRunning("t17")))
#expect(exitCount(in: streamBox) == 1)
#expect(exitCount(in: capBox) == 1)
let t16RunningAfter = await pm.isRunning("t16")
XCTAssertFalse(t16RunningAfter)
let t17RunningAfter = await pm.isRunning("t17")
XCTAssertFalse(t17RunningAfter)
XCTAssertEqual(exitCount(in: streamBox), 1)
XCTAssertEqual(exitCount(in: capBox), 1)
}
}
@Suite("ProcessLineDecoder")
struct ProcessLineDecoderTests {
@Test func splitsAcrossChunkBoundaries() {
final class ProcessLineDecoderTests: XCTestCase {
func testSplitsAcrossChunkBoundaries() {
var d = ProcessLineDecoder()
#expect(d.feed(Data("he".utf8)) == [])
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
#expect(d.feed(Data("ld\n".utf8)) == ["world"])
#expect(d.finish() == nil)
XCTAssertEqual(d.feed(Data("he".utf8)), [])
XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"])
XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"])
XCTAssertNil(d.finish())
}
@Test func crlfIsStripped() {
func testCrlfIsStripped() {
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()
_ = d.feed(Data("x".utf8))
#expect(d.finish() == "x")
#expect(d.finish() == nil)
XCTAssertEqual(d.finish(), "x")
XCTAssertNil(d.finish())
}
}
@Suite("JSONAccumulator")
struct JSONAccumulatorTests {
@Test func multilinePrettyJSON() {
final class JSONAccumulatorTests: XCTestCase {
func testMultilinePrettyJSON() {
var acc = JSONAccumulator()
#expect(acc.feed(line: "{") == nil)
#expect(acc.feed(line: " \"k\": 1") == nil)
XCTAssertNil(acc.feed(line: "{"))
XCTAssertNil(acc.feed(line: " \"k\": 1"))
let done = acc.feed(line: "}")
#expect(done != nil)
XCTAssertNotNil(done)
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()
#expect(acc.feed(line: "Reading instrument...") == nil)
#expect(acc.feed(line: "still text") == nil)
#expect(acc.completeData == nil)
XCTAssertNil(acc.feed(line: "Reading instrument..."))
XCTAssertNil(acc.feed(line: "still text"))
XCTAssertNil(acc.completeData)
}
@Test func decodeTyped() {
func testDecodeTyped() {
struct Doc: Decodable { let n: Int }
var acc = JSONAccumulator()
// 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}")
#expect(data != nil)
XCTAssertNotNil(data)
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
#expect(doc?.n == 7)
#expect(acc.isEmpty)
XCTAssertEqual(doc?.n, 7)
XCTAssertTrue(acc.isEmpty)
}
}
@Suite("LogSanitizer")
struct LogSanitizerTests {
@Test func homeIsRewritten() {
final class LogSanitizerTests: XCTestCase {
func testHomeIsRewritten() {
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1")
}
}
@@ -1,5 +1,5 @@
import Foundation
import Testing
import XCTest
@testable import ICCery
/// Direct contracts for the shared logged-run helper (issue #80).
@@ -7,14 +7,12 @@ import Testing
/// `runLogged` owns the running-flag transition (`false true false`)
/// and the log-reset decision; these tests pin both sides of the
/// contract plus the coalesced `@MainActor` log hop.
@Suite("ProcessRunSupport runLogged")
@MainActor
struct ProcessRunSupportTests {
final class ProcessRunSupportTests: XCTestCase {
private struct SentinelError: Error {}
@Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved")
func successTransitions() async throws {
func testSuccessTransitions() async throws {
var running: [Bool] = []
var resets = 0
var received: [String] = []
@@ -31,20 +29,19 @@ struct ProcessRunSupportTests {
return 42
}
#expect(result == 42)
#expect(running == [true, false])
#expect(resets == 1)
XCTAssertEqual(result, 42)
XCTAssertEqual(running, [true, false])
XCTAssertEqual(resets, 1)
// The sink hops back through a main-actor Task; yield until the
// coalesced batch lands.
for _ in 0..<200 where received.isEmpty {
try await Task.sleep(for: .milliseconds(10))
try await Task.sleep(nanoseconds: 10_000_000)
}
#expect(received == ["alpha", "beta"])
XCTAssertEqual(received, ["alpha", "beta"])
}
@Test("Failure: running still transitions [true, false], log resets once, error is rethrown")
func failureTransitions() async throws {
func testFailureTransitions() async throws {
var running: [Bool] = []
var resets = 0
@@ -56,12 +53,12 @@ struct ProcessRunSupportTests {
) { _ -> Int in
throw SentinelError()
}
Issue.record("Expected runLogged to rethrow")
XCTFail("Expected runLogged to rethrow")
} catch is SentinelError {
// Expected path.
}
#expect(running == [true, false])
#expect(resets == 1)
XCTAssertEqual(running, [true, false])
XCTAssertEqual(resets, 1)
}
}
@@ -1,17 +1,15 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ProfcheckArgs")
struct ProfcheckArgsTests {
final class ProfcheckArgsTests: XCTestCase {
@Test("Hard-coded argv")
func argv() throws {
func testArgv() throws {
let config = ProfcheckConfig(
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
iccURL: URL(fileURLWithPath: "/tmp/target.icc")
)
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 Testing
import XCTest
@testable import ICCeryCore
@Suite("ProfcheckParser")
struct ProfcheckParserTests {
final class ProfcheckParserTests: XCTestCase {
@Test("Prefers JSON report with de2000 keys")
func jsonReport() {
func testJsonReport() {
let output = """
No of test patches = 52
{"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
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.patchCount == 52)
#expect(report.avgDE == 0.85)
#expect(report.maxDE == 2.41)
#expect(report.rmsDE == 1.02)
#expect(report.status == .excellent)
XCTAssertEqual(report.isValid, true)
XCTAssertEqual(report.patchCount, 52)
XCTAssertEqual(report.avgDE, 0.85)
XCTAssertEqual(report.maxDE, 2.41)
XCTAssertEqual(report.rmsDE, 1.02)
XCTAssertEqual(report.status, .excellent)
}
@Test("Falls back to legacy text")
func legacyText() {
func testLegacyText() {
let output = """
No of test patches = 120
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.patchCount == 120)
#expect(report.avgDE == 1.80)
#expect(report.maxDE == 3.50)
#expect(report.rmsDE == 0.95)
#expect(report.status == .good)
XCTAssertEqual(report.isValid, true)
XCTAssertEqual(report.patchCount, 120)
XCTAssertEqual(report.avgDE, 1.80)
XCTAssertEqual(report.maxDE, 3.50)
XCTAssertEqual(report.rmsDE, 0.95)
XCTAssertEqual(report.status, .good)
}
@Test("Broad regex fallback")
func regexFallback() {
func testRegexFallback() {
let output = """
No of test patches = 10
avg = 4.25
@@ -45,27 +41,25 @@ struct ProfcheckParserTests {
rms = 2.30
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.avgDE == 4.25)
#expect(report.maxDE == 6.10)
#expect(report.rmsDE == 2.30)
#expect(report.status == .poor)
XCTAssertEqual(report.isValid, true)
XCTAssertEqual(report.avgDE, 4.25)
XCTAssertEqual(report.maxDE, 6.10)
XCTAssertEqual(report.rmsDE, 2.30)
XCTAssertEqual(report.status, .poor)
}
@Test("Unparseable output warns, not zeros")
func unparseable() {
func testUnparseable() {
let output = "some random text without metrics"
let report = ProfcheckParser.parse(output)
#expect(report.isValid == false)
#expect(report.warning != nil)
#expect(report.avgDE == nil)
XCTAssertEqual(report.isValid, false)
XCTAssertNotNil(report.warning)
XCTAssertNil(report.avgDE)
}
@Test("Status bands")
func statusBands() {
#expect(VerificationStatus.from(avgDE: 0.5) == .excellent)
#expect(VerificationStatus.from(avgDE: 1.5) == .good)
#expect(VerificationStatus.from(avgDE: 2.5) == .acceptable)
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
func testStatusBands() {
XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent)
XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good)
XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable)
XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor)
}
}
@@ -1,5 +1,5 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
/// A `FileManager` subclass that reports a temporary directory as the
@@ -18,8 +18,7 @@ private final class TestFileManager: FileManager {
}
}
@Suite("ProfileInstaller")
struct ProfileInstallerTests {
final class ProfileInstallerTests: XCTestCase {
private func makeTempDir() throws -> URL {
let fm = FileManager.default
@@ -39,8 +38,7 @@ struct ProfileInstallerTests {
return url
}
@Test("Installs .icc to user ColorSync folder")
func userInstall() throws {
func testUserInstall() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
@@ -51,15 +49,14 @@ struct ProfileInstallerTests {
fileManager: testFM
)
#expect(result.registered)
#expect(!result.overwritten)
#expect(!result.renamed)
#expect(result.destPath.hasSuffix("test.icc"))
#expect(fm.fileExists(atPath: result.destPath))
XCTAssertTrue(result.registered)
XCTAssertFalse(result.overwritten)
XCTAssertFalse(result.renamed)
XCTAssertTrue(result.destPath.hasSuffix("test.icc"))
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
}
@Test("Overwrite succeeds and replaces the existing file")
func overwriteSucceeds() throws {
func testOverwriteSucceeds() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
@@ -70,7 +67,7 @@ struct ProfileInstallerTests {
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#expect(!first.overwritten)
XCTAssertFalse(first.overwritten)
// Change the source contents.
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
@@ -87,15 +84,14 @@ struct ProfileInstallerTests {
fileManager: testFM
)
#expect(second.overwritten)
#expect(!second.renamed)
#expect(fm.fileExists(atPath: second.destPath))
XCTAssertTrue(second.overwritten)
XCTAssertFalse(second.renamed)
XCTAssertTrue(fm.fileExists(atPath: second.destPath))
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
#expect(Array(installed) == newBytes)
XCTAssertEqual(Array(installed), newBytes)
}
@Test("Preserves .icm source extension")
func preservesIcmExtension() throws {
func testPreservesIcmExtension() throws {
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icm")
@@ -105,12 +101,11 @@ struct ProfileInstallerTests {
fileManager: testFM
)
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm")
#expect(result.destPath.hasSuffix("m5_profile.icm"))
XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm")
XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm"))
}
@Test("Rejects parent traversal in source path")
func rejectsParentTraversal() throws {
func testRejectsParentTraversal() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
@@ -125,20 +120,19 @@ struct ProfileInstallerTests {
let sourceURL = tmp
.appendingPathComponent("..")
.appendingPathComponent(naughtyName)
#expect(fm.fileExists(atPath: sourceURL.path))
XCTAssertTrue(fm.fileExists(atPath: sourceURL.path))
do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
Issue.record("Expected unsafeStem error")
XCTFail("Expected unsafeStem error")
} catch let error as ProfileInstallError {
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
if case .unsafeStem = error { } else { XCTFail("Expected unsafeStem, got \(error)") }
} catch {
Issue.record("Unexpected error type: \(error)")
XCTFail("Unexpected error type: \(error)")
}
}
@Test("Allows stems with consecutive dots like foo..bar")
func allowsDoubleDotStem() throws {
func testAllowsDoubleDotStem() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
@@ -149,23 +143,22 @@ struct ProfileInstallerTests {
fileManager: testFM
)
#expect(result.destPath.hasSuffix("foo..bar.icc"))
#expect(fm.fileExists(atPath: result.destPath))
XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc"))
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
}
@Test("Rejects source files that are too small")
func rejectsSmallSource() throws {
func testRejectsSmallSource() throws {
let tmp = try makeTempDir()
let source = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: source)
do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
Issue.record("Expected sourceTooSmall error")
XCTFail("Expected sourceTooSmall error")
} catch let error as ProfileInstallError {
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
if case .sourceTooSmall = error { } else { XCTFail("Expected sourceTooSmall, got \(error)") }
} catch {
Issue.record("Unexpected error type: \(error)")
XCTFail("Unexpected error type: \(error)")
}
}
}
+54 -57
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
private func tempStoreURL() -> URL {
@@ -8,92 +8,90 @@ private func tempStoreURL() -> URL {
.appendingPathComponent("settings.json")
}
@Suite("AppSettings")
struct AppSettingsTests {
@Test func defaults() {
final class AppSettingsTests: XCTestCase {
func testDefaults() {
let s = AppSettings.default
#expect(s.argyllBinaryDir == nil)
#expect(s.defaultInstrument == nil)
#expect(s.logLevel == nil)
#expect(s.deltaEGoodMax == 2.0)
#expect(s.deltaEWarningMax == 5.0)
#expect(s.customPresets.isEmpty)
#expect(!s.enableI1Pro2Leds)
#expect(s.calibrationStaleDays == 30)
#expect(s.defaultInstallLocation == .user)
#expect(s.askBeforeOverwriteProfile)
#expect(!s.openColorPanelAfterInstall)
#expect(s.isValid)
XCTAssertNil(s.argyllBinaryDir)
XCTAssertNil(s.defaultInstrument)
XCTAssertNil(s.logLevel)
XCTAssertEqual(s.deltaEGoodMax, 2.0)
XCTAssertEqual(s.deltaEWarningMax, 5.0)
XCTAssertTrue(s.customPresets.isEmpty)
XCTAssertFalse(s.enableI1Pro2Leds)
XCTAssertEqual(s.calibrationStaleDays, 30)
XCTAssertEqual(s.defaultInstallLocation, .user)
XCTAssertTrue(s.askBeforeOverwriteProfile)
XCTAssertFalse(s.openColorPanelAfterInstall)
XCTAssertTrue(s.isValid)
}
@Test func negativeThresholds() {
func testNegativeThresholds() {
var s = AppSettings.default
s.deltaEGoodMax = -1
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE])
s.deltaEGoodMax = 2.0
s.deltaEWarningMax = -0.5
// -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too
#expect(s.validate() == [
XCTAssertTrue(s.validate() == [
AppSettings.errorNegativeDeltaE,
AppSettings.errorThresholdOrder,
])
}
@Test func goodMustBeStrictlyLessThanWarning() {
func testGoodMustBeStrictlyLessThanWarning() {
var s = AppSettings.default
s.deltaEGoodMax = 5.0
#expect(s.validate() == [AppSettings.errorThresholdOrder])
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 6.0
#expect(s.validate() == [AppSettings.errorThresholdOrder])
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 4.9
#expect(s.isValid)
XCTAssertTrue(s.isValid)
}
@Test func snakeCaseKeys() throws {
func testSnakeCaseKeys() throws {
let s = AppSettings.default
let data = try JSONEncoder().encode(s)
let json = String(data: data, encoding: .utf8)!
#expect(json.contains("\"delta_e_good_max\""))
#expect(json.contains("\"default_install_location\""))
#expect(json.contains("\"enable_i1pro2_leds\""))
XCTAssertTrue(json.contains("\"delta_e_good_max\""))
XCTAssertTrue(json.contains("\"default_install_location\""))
XCTAssertTrue(json.contains("\"enable_i1pro2_leds\""))
}
}
@Suite("SettingsStore")
struct SettingsStoreTests {
@Test func roundTrip() throws {
final class SettingsStoreTests: XCTestCase {
func testRoundTrip() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var s = AppSettings.default
s.deltaEGoodMax = 1.5
s.defaultInstrument = "p3"
try store.save(s)
#expect(store.load() == s)
XCTAssertEqual(store.load(), s)
}
@Test func corruptJsonFallsBackToDefaults() throws {
func testCorruptJsonFallsBackToDefaults() throws {
let url = tempStoreURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
#expect(SettingsStore(fileURL: url).load() == .default)
XCTAssertEqual(SettingsStore(fileURL: url).load(), .default)
}
@Test func missingFileReturnsDefaults() {
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
func testMissingFileReturnsDefaults() {
XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default)
}
@Test func invalidSettingsNotPersisted() throws {
func testInvalidSettingsNotPersisted() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var s = AppSettings.default
s.deltaEGoodMax = 9.0 // >= warning 5.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
#expect(!FileManager.default.fileExists(atPath: url.path))
XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var valid = AppSettings.default
@@ -109,13 +107,13 @@ struct SettingsStoreTests {
var invalid = AppSettings.default
invalid.deltaEGoodMax = 9.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
#expect(try Data(contentsOf: url) == originalBytes)
#expect(!fired)
#expect(store.load() == valid)
XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
XCTAssertEqual(try Data(contentsOf: url), originalBytes)
XCTAssertFalse(fired)
XCTAssertEqual(store.load(), valid)
}
@Test func savePostsNotification() async throws {
func testSavePostsNotification() async throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var fired = false
@@ -124,12 +122,11 @@ struct SettingsStoreTests {
) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) }
try store.save(.default)
#expect(fired)
XCTAssertTrue(fired)
}
}
@Suite("LogSink")
struct LogSinkTests {
final class LogSinkTests: XCTestCase {
private func tempLog() -> (URL, LogSink) {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
@@ -137,26 +134,26 @@ struct LogSinkTests {
return (url, LogSink(fileURL: url))
}
@Test func writesFormattedLines() {
func testWritesFormattedLines() {
let (url, sink) = tempLog()
sink.setLevel(.debug)
sink.write(level: .info, category: "test", message: "hello")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(content.contains("[INFO] test: hello"))
XCTAssertTrue(content.contains("[INFO] test: hello"))
}
@Test func levelFilteringIsLive() {
func testLevelFilteringIsLive() {
let (url, sink) = tempLog()
sink.setLevel(.error)
sink.write(level: .info, category: "t", message: "hidden")
sink.setLevel(.info) // runtime change, no restart (#158)
sink.write(level: .info, category: "t", message: "shown")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(!content.contains("hidden"))
#expect(content.contains("shown"))
XCTAssertFalse(content.contains("hidden"))
XCTAssertTrue(content.contains("shown"))
}
@Test func rotatesAt5MiBKeeping5Segments() throws {
func testRotatesAt5MiBKeeping5Segments() throws {
let (url, sink) = tempLog()
sink.setLevel(.trace)
// Pre-fill the active log just under the cap, then cross it.
@@ -167,20 +164,20 @@ struct LogSinkTests {
try big.write(to: url, atomically: true, encoding: .utf8)
sink.write(level: .info, category: "t", message: "trigger rotation")
#expect(FileManager.default.fileExists(
XCTAssertTrue(FileManager.default.fileExists(
atPath: url.appendingPathExtension("1").path
))
// Active log is small again.
let size = try FileManager.default.attributesOfItem(
atPath: url.path
)[.size] as? UInt64
#expect((size ?? 0) < 1024)
XCTAssertTrue((size ?? 0) < 1024)
}
@Test func tailExcerptCaps() throws {
func testTailExcerptCaps() throws {
let (url, sink) = tempLog()
sink.setLevel(.debug)
sink.write(level: .info, category: "t", message: "line")
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8)
}
}
+53 -68
View File
@@ -1,12 +1,10 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@Suite("TargenArgs")
struct TargenArgsTests {
final class TargenArgsTests: XCTestCase {
@Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4")
func rgbBaseline() throws {
func testRgbBaseline() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -15,12 +13,11 @@ struct TargenArgsTests {
basename: "test_rgb"
)
let args = try TargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
#expect(!args.contains("-u"))
XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
XCTAssertFalse(args.contains("-u"))
}
@Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0")
func cmykBaseline() throws {
func testCmykBaseline() throws {
let config = TargenConfig(
colourSpace: .cmyk,
patchCount: 1500,
@@ -29,11 +26,10 @@ struct TargenArgsTests {
basename: "test_cmyk"
)
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 customPatchCount() throws {
func testCustomPatchCount() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 2500,
@@ -42,12 +38,11 @@ struct TargenArgsTests {
basename: "custom_patches"
)
let args = try TargenArgs.build(config: config)
#expect(args.contains("-f"))
#expect(args[args.firstIndex(of: "-f")! + 1] == "2500")
XCTAssertTrue(args.contains("-f"))
XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500")
}
@Test("All advanced flags in stable order")
func allAdvancedFlags() throws {
func testAllAdvancedFlags() throws {
let config = TargenConfig(
colourSpace: .cmyk,
patchCount: 1200,
@@ -85,11 +80,10 @@ struct TargenArgsTests {
"-p", "2.00",
"advanced_cmyk"
]
#expect(args == expected)
XCTAssertEqual(args, expected)
}
@Test("RGB ignores total ink limit")
func rgbIgnoresInkLimit() throws {
func testRgbIgnoresInkLimit() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -99,11 +93,10 @@ struct TargenArgsTests {
basename: "rgb_no_ink"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("-l"))
XCTAssertFalse(args.contains("-l"))
}
@Test("Neutral concentration omitted when approximately 0.50")
func neutralConcentrationOmittedWhenDefault() throws {
func testNeutralConcentrationOmittedWhenDefault() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -113,11 +106,10 @@ struct TargenArgsTests {
basename: "n_default"
)
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 adaptationEmittedAtPointOne() throws {
func testAdaptationEmittedAtPointOne() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -127,12 +119,11 @@ struct TargenArgsTests {
basename: "a_flag"
)
let args = try TargenArgs.build(config: config)
#expect(args.contains("-A"))
#expect(args[args.firstIndex(of: "-A")! + 1] == "0.10")
XCTAssertTrue(args.contains("-A"))
XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10")
}
@Test("OFPS full spread algorithm emits no flag")
func ofpsEmitsNoFlag() throws {
func testOfpsEmitsNoFlag() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -142,12 +133,11 @@ struct TargenArgsTests {
basename: "ofps_test"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("ofps"))
#expect(!args.contains("-t"))
XCTAssertFalse(args.contains("ofps"))
XCTAssertFalse(args.contains("-t"))
}
@Test("Dark emphasis and device power omitted when 1.0")
func darkEmphasisAndPowerOmittedWhenOne() throws {
func testDarkEmphasisAndPowerOmittedWhenOne() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -158,12 +148,11 @@ struct TargenArgsTests {
basename: "defaults_omitted"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("-V"))
#expect(!args.contains("-p"))
XCTAssertFalse(args.contains("-V"))
XCTAssertFalse(args.contains("-p"))
}
@Test("Whitespace-only preconditioning profile emits no -c")
func whitespacePreconditioner() throws {
func testWhitespacePreconditioner() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -173,11 +162,10 @@ struct TargenArgsTests {
basename: "ws_pre"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("-c"))
XCTAssertFalse(args.contains("-c"))
}
@Test("Preconditioning profile is trimmed before emission")
func preconditionerTrimmed() throws {
func testPreconditionerTrimmed() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -187,11 +175,10 @@ struct TargenArgsTests {
basename: "trim_pre"
)
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 invalidBasenameThrows() {
func testInvalidBasenameThrows() {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
@@ -199,13 +186,12 @@ struct TargenArgsTests {
blackPatches: 4,
basename: "../bad_name"
)
#expect(throws: PathSecurity.Error.self) {
try TargenArgs.build(config: config)
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
XCTAssertTrue(error is PathSecurity.Error)
}
}
@Test("Invalid patch count throws")
func invalidPatchCountThrows() {
func testInvalidPatchCountThrows() {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 0,
@@ -213,13 +199,12 @@ struct TargenArgsTests {
blackPatches: 4,
basename: "bad_count"
)
#expect(throws: TargenArgError.self) {
try TargenArgs.build(config: config)
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
XCTAssertTrue(error is TargenArgError)
}
}
@Test("Invalid ink limit throws for CMYK")
func invalidInkLimitThrows() {
func testInvalidInkLimitThrows() {
let config = TargenConfig(
colourSpace: .cmyk,
patchCount: 800,
@@ -228,17 +213,15 @@ struct TargenArgsTests {
totalInkLimit: 450,
basename: "bad_ink"
)
#expect(throws: TargenArgError.self) {
try TargenArgs.build(config: config)
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
XCTAssertTrue(error is TargenArgError)
}
}
}
@Suite("ArgyllRunner Targen")
struct ArgyllRunnerTargenTests {
final class ArgyllRunnerTargenTests: XCTestCase {
@Test("Successful targen execution creates .ti1 and returns URL")
func successfulTargenExecution() async throws {
func testSuccessfulTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -284,14 +267,13 @@ struct ArgyllRunnerTargenTests {
box.append(batch)
}
logLines = box.lines
#expect(logLines.contains("Generating patches..."))
XCTAssertTrue(logLines.contains("Generating patches..."))
#expect(FileManager.default.fileExists(atPath: ti1URL.path))
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path))
XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1")
}
@Test("Failed targen execution throws toolFailed")
func failedTargenExecution() async throws {
func testFailedTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -318,14 +300,15 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir
)
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "targen", code: 1, logs: ["Error: something went wrong"]))
}
}
@Test("Targen exit 0 without .ti1 throws missingArtefact")
func missingArtefactThrows() async throws {
func testMissingArtefactThrows() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -352,9 +335,11 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir
)
await #expect(throws: ArgyllRunnerError.missingArtefact(
tempDir.appendingPathComponent("no_file.ti1").path)) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(
tempDir.appendingPathComponent("no_file.ti1").path))
}
}
}
@@ -1,17 +1,15 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Dataset-import error contracts through the
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
/// failures must surface identically as a single `.error` Notice.
@Suite("TargetWorkflowViewModel dataset import")
@MainActor
struct TargetWorkflowViewModelTests {
final class TargetWorkflowViewModelTests: XCTestCase {
@Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'")
func malformedDatasetNotice() throws {
func testMalformedDatasetNotice() throws {
let env = try TestAppEnvironment.make()
defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment)
@@ -21,13 +19,12 @@ struct TargetWorkflowViewModelTests {
vm.importMeasurementDataset(from: bad)
let notice = try #require(vm.wizard.notice)
#expect(notice.kind == .error)
#expect(notice.text.hasPrefix("Import failed:"))
let notice = try XCTUnwrap(vm.wizard.notice)
XCTAssertEqual(notice.kind, .error)
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
}
@Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'")
func missingDatasetNotice() throws {
func testMissingDatasetNotice() throws {
let env = try TestAppEnvironment.make()
defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment)
@@ -35,8 +32,8 @@ struct TargetWorkflowViewModelTests {
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
vm.importMeasurementDataset(from: missing)
let notice = try #require(vm.wizard.notice)
#expect(notice.kind == .error)
#expect(notice.text.hasPrefix("Import failed:"))
let notice = try XCTUnwrap(vm.wizard.notice)
XCTAssertEqual(notice.kind, .error)
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
}
}
@@ -1,4 +1,3 @@
import Testing
import Foundation
@testable import ICCeryCore
@testable import ICCery
@@ -1,12 +1,10 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("VerificationHistoryStore")
struct VerificationHistoryStoreTests {
final class VerificationHistoryStoreTests: XCTestCase {
@Test("Append and cap")
func appendAndCap() async throws {
func testAppendAndCap() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -29,12 +27,11 @@ struct VerificationHistoryStoreTests {
}
let all = await store.all()
#expect(all.count == 3)
#expect(all.first?.avgDE == 2.0)
XCTAssertEqual(all.count, 3)
XCTAssertEqual(all.first?.avgDE, 2.0)
}
@Test("Parse failure preserves file")
func parseFailurePreservesFile() async {
func testParseFailurePreservesFile() async {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -45,14 +42,13 @@ struct VerificationHistoryStoreTests {
let store = VerificationHistoryStore(url: url)
do {
_ = try await store.load()
Issue.record("load() should throw on invalid JSON")
XCTFail("load() should throw on invalid JSON")
} catch {
#expect(fm.fileExists(atPath: url.path))
XCTAssertTrue(fm.fileExists(atPath: url.path))
}
}
@Test("Append loads existing records first")
func appendLoadsExisting() async throws {
func testAppendLoadsExisting() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -89,13 +85,12 @@ struct VerificationHistoryStoreTests {
_ = try await store2.append(new)
let all = await store2.all()
#expect(all.count == 2)
#expect(all.contains { $0.id == "vr-existing" })
#expect(all.contains { $0.id == "vr-new" })
XCTAssertEqual(all.count, 2)
XCTAssertTrue(all.contains { $0.id == "vr-existing" })
XCTAssertTrue(all.contains { $0.id == "vr-new" })
}
@Test("Append does not overwrite an unparseable file")
func appendPreservesUnparseableFile() async {
func testAppendPreservesUnparseableFile() async {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -119,20 +114,19 @@ struct VerificationHistoryStoreTests {
do {
_ = try await store.append(record)
Issue.record("append() should propagate the load error")
XCTFail("append() should propagate the load error")
} catch {
#expect(fm.fileExists(atPath: url.path))
XCTAssertTrue(fm.fileExists(atPath: url.path))
if let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8) {
#expect(contents == badJSON)
XCTAssertEqual(contents, badJSON)
} else {
Issue.record("Could not read preserved file")
XCTFail("Could not read preserved file")
}
}
}
@Test("Clear does not overwrite an unparseable file")
func clearPreservesUnparseableFile() async {
func testClearPreservesUnparseableFile() async {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -144,15 +138,14 @@ struct VerificationHistoryStoreTests {
let store = VerificationHistoryStore(url: url)
do {
try await store.clear()
Issue.record("clear() should propagate the load error")
XCTFail("clear() should propagate the load error")
} catch {
let contents = try? String(contentsOf: url, encoding: .utf8)
#expect(contents == badJSON)
XCTAssertEqual(contents, badJSON)
}
}
@Test("ISO-8601 timestamps round-trip through a fresh store")
func iso8601RoundTrip() async throws {
func testIso8601RoundTrip() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -174,16 +167,15 @@ struct VerificationHistoryStoreTests {
_ = try await store1.append(record)
let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains(ISO8601DateFormatter().string(from: timestamp)))
XCTAssertTrue(text.contains(ISO8601DateFormatter().string(from: timestamp)))
let store2 = VerificationHistoryStore(url: url)
let loaded = try await store2.load()
#expect(loaded.count == 1)
#expect(loaded.first?.timestamp == timestamp)
XCTAssertEqual(loaded.count, 1)
XCTAssertEqual(loaded.first?.timestamp, timestamp)
}
@Test("CSV export quoting")
func csvQuoting() async throws {
func testCsvQuoting() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -204,7 +196,7 @@ struct VerificationHistoryStoreTests {
_ = try await store.append(record)
let csv = await store.exportCSV()
#expect(csv.contains("\"a,b\""))
#expect(csv.contains("\"\"quoted\"\""))
XCTAssertTrue(csv.contains("\"a,b\""))
XCTAssertTrue(csv.contains("\"\"quoted\"\""))
}
}
@@ -1,13 +1,12 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #29 `CAL_` basename must be restored on relaunch and on any
/// attempt to navigate to a non-calibration stage that would use it.
@Suite("WizardCalibrationSession")
@MainActor
struct WizardCalibrationSessionTests {
final class WizardCalibrationSessionTests: XCTestCase {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
@@ -24,8 +23,7 @@ struct WizardCalibrationSessionTests {
return url
}
@Test("Persist and restore calibrationOriginalBasename across a relaunch")
func relaunchRestoresOriginal() throws {
func testRelaunchRestoresOriginal() throws {
let url = tempURL()
let store = WizardStateStore(fileURL: url)
var saved = WizardState(
@@ -39,14 +37,13 @@ struct WizardCalibrationSessionTests {
let model = WizardViewModel(stateStore: store)
#expect(model.basename == "DemoTarget")
#expect(model.calibrationOriginalBasename == "")
#expect(model.sessionMode == .profile)
#expect(model.stage == .generate)
XCTAssertEqual(model.basename, "DemoTarget")
XCTAssertEqual(model.calibrationOriginalBasename, "")
XCTAssertEqual(model.sessionMode, .profile)
XCTAssertEqual(model.stage, .generate)
}
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original")
func goToBuildProfileRefusesAndRestores() throws {
func testGoToBuildProfileRefusesAndRestores() throws {
let dir = try tempDir()
let url = tempURL()
let store = WizardStateStore(fileURL: url)
@@ -60,14 +57,13 @@ struct WizardCalibrationSessionTests {
model.go(to: .buildProfile)
#expect(model.basename == "DemoTarget")
#expect(model.calibrationOriginalBasename == "")
#expect(model.sessionMode == .profile)
#expect(model.stage == .calibrate)
XCTAssertEqual(model.basename, "DemoTarget")
XCTAssertEqual(model.calibrationOriginalBasename, "")
XCTAssertEqual(model.sessionMode, .profile)
XCTAssertEqual(model.stage, .calibrate)
}
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration")
func goToLayoutStaysCal() throws {
func testGoToLayoutStaysCal() throws {
let dir = try tempDir()
let url = tempURL()
let store = WizardStateStore(fileURL: url)
@@ -81,9 +77,9 @@ struct WizardCalibrationSessionTests {
model.go(to: .layOutPrint)
#expect(model.basename == "CAL_DemoTarget")
#expect(model.calibrationOriginalBasename == "DemoTarget")
#expect(model.sessionMode == .calibration)
#expect(model.stage == .layOutPrint)
XCTAssertEqual(model.basename, "CAL_DemoTarget")
XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget")
XCTAssertEqual(model.sessionMode, .calibration)
XCTAssertEqual(model.stage, .layOutPrint)
}
}
+47 -49
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
private func artefacts(
@@ -16,77 +16,75 @@ private func artefacts(
return a
}
@Suite("WizardGating matrix")
struct WizardGatingTests {
final class WizardGatingTests: XCTestCase {
@Test func emptyProjectOnlyStage1() {
func testEmptyProjectOnlyStage1() {
let a = artefacts()
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a))
XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a))
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
XCTAssertFalse(WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
}
}
@Test func ti1UnlocksStage2Only() {
func testTi1UnlocksStage2Only() {
let a = artefacts(ti1: true)
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a))
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a))
}
@Test func stage3NeedsTi1AndTi2() {
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
func testStage3NeedsTi1AndTi2() {
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
XCTAssertTrue(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
}
@Test func stage4NeedsTi3NotTi2() {
func testStage4NeedsTi3NotTi2() {
// #109/#110: .ti2 alone must never unlock Stage 4.
let a = artefacts(ti1: true, ti2: true)
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
}
@Test func stage5NeedsTi3AndProfile() {
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
#expect(WizardGating.isUnlocked(
func testStage5NeedsTi3AndProfile() {
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
XCTAssertTrue(WizardGating.isUnlocked(
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
))
}
@Test func forwardGatedBackwardFree() {
func testForwardGatedBackwardFree() {
let a = artefacts()
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
XCTAssertFalse(WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
// Backward always allowed even when artefacts vanished.
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
XCTAssertTrue(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
// Same stage is a no-op.
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
XCTAssertTrue(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
// Stage 0 is a side-trip, never gated.
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
XCTAssertTrue(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
}
@Test func deepestUnlocked() {
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
#expect(WizardGating.deepestUnlocked(
func testDeepestUnlocked() {
XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate)
XCTAssertEqual(WizardGating.deepestUnlocked(
artefacts: artefacts(ti1: true, ti2: true)
) == .measure)
#expect(WizardGating.deepestUnlocked(
), .measure)
XCTAssertEqual(WizardGating.deepestUnlocked(
artefacts: artefacts(ti3: true, profile: true)
) == .verifyInstall)
), .verifyInstall)
}
}
@Suite("WizardStateStore")
struct WizardStateStoreTests {
final class WizardStateStoreTests: XCTestCase {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
.appendingPathComponent("wizard_state.json")
}
@Test func roundTrip() throws {
func testRoundTrip() throws {
let url = tempURL()
let store = WizardStateStore(fileURL: url)
var s = WizardState()
@@ -97,43 +95,43 @@ struct WizardStateStoreTests {
s.profileBasename = "imported"
s.calibrationOriginalBasename = "pre-cal"
try store.save(s)
#expect(store.load() == s)
XCTAssertEqual(store.load(), s)
}
@Test func missingFileDefaults() {
func testMissingFileDefaults() {
let s = WizardStateStore(fileURL: tempURL()).load()
#expect(s == .default)
#expect(s.stage == .generate)
#expect(s.sessionMode == .profile)
XCTAssertEqual(s, .default)
XCTAssertEqual(s.stage, .generate)
XCTAssertEqual(s.sessionMode, .profile)
}
@Test func corruptStageFallsBackToGenerate() throws {
func testCorruptStageFallsBackToGenerate() throws {
let url = tempURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
.write(to: url, atomically: true, encoding: .utf8)
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
XCTAssertEqual(WizardStateStore(fileURL: url).load().stage, .generate)
}
@Test func corruptJsonReturnsDefaultAndKeepsBytes() throws {
func testCorruptJsonReturnsDefaultAndKeepsBytes() throws {
let url = tempURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "not json".write(to: url, atomically: true, encoding: .utf8)
#expect(WizardStateStore(fileURL: url).load() == .default)
XCTAssertEqual(WizardStateStore(fileURL: url).load(), .default)
let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json")
XCTAssertEqual(kept, "not json")
}
@Test func sessionModeCalibrationRoundTrips() throws {
func testSessionModeCalibrationRoundTrips() throws {
var s = WizardState(sessionMode: .calibration)
let data = try JSONEncoder().encode(s)
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
#expect(decoded.sessionMode == .calibration)
XCTAssertEqual(decoded.sessionMode, .calibration)
s.sessionMode = .profile
#expect(s.sessionMode == .profile)
XCTAssertEqual(s.sessionMode, .profile)
}
}
@@ -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:
bundleIdPrefix: com.gronod
deploymentTarget:
macOS: "14.0"
macOS: "12.0"
groupSortPosition: top
packages:
@@ -13,7 +13,7 @@ targets:
ICCery:
type: application
platform: macOS
deploymentTarget: "14.0"
deploymentTarget: "12.0"
sources:
- path: Sources/ICCery
- path: Resources
@@ -46,7 +46,7 @@ targets:
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: ICCery
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0"
@@ -58,15 +58,15 @@ targets:
ENABLE_APP_SANDBOX: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
MACOSX_DEPLOYMENT_TARGET: "14.0"
SWIFT_VERSION: "5.0"
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
MACOSX_DEPLOYMENT_TARGET: "12.0"
ARCHS: "$(ARCHS_STANDARD)"
ICCeryCoreTests:
type: bundle.unit-test
platform: macOS
deploymentTarget: "14.0"
deploymentTarget: "12.0"
sources:
- path: Tests/ICCeryCoreTests
dependencies:
@@ -79,13 +79,13 @@ targets:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "14.0"
SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "12.0"
ICCeryUITests:
type: bundle.ui-testing
platform: macOS
deploymentTarget: "14.0"
deploymentTarget: "12.0"
sources:
- path: Tests/ICCeryUITests
dependencies:
@@ -95,8 +95,8 @@ targets:
TEST_TARGET_NAME: ICCery
GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "14.0"
SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "12.0"
schemes:
ICCery: