Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a30a8fc551 | ||
|
|
83f3a4f0e2 |
@@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("ArgyllRunner Calibration")
|
final class ArgyllRunnerCalibrationTests: XCTestCase {
|
||||||
struct ArgyllRunnerCalibrationTests {
|
|
||||||
|
|
||||||
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
||||||
let binDir = URL(fileURLWithPath: #filePath)
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Calibration targen produces CAL_*.ti1")
|
func testCalibrationTargenProducesTi1() async throws {
|
||||||
func calibrationTargenProducesTi1() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let runner = makeRunner()
|
let runner = makeRunner()
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
@@ -36,13 +34,12 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
|
|
||||||
let url = try await runner.runCalibrationTargen(config: config)
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "CAL_demo.ti1")
|
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
|
func testCalibrationTargenProcessId() async throws {
|
||||||
func calibrationTargenProcessId() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let runner = makeRunner(processManager: pm)
|
let runner = makeRunner(processManager: pm)
|
||||||
@@ -65,13 +62,13 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
|
|
||||||
let url = try await runner.runCalibrationTargen(config: config)
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "CAL_foo.ti1")
|
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
|
||||||
#expect(await sawExit.value)
|
let sawExitEvent = await sawExit.value
|
||||||
|
XCTAssertTrue(sawExitEvent)
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal captured run creates .cal")
|
func testPrintcalProducesCal() async throws {
|
||||||
func printcalProducesCal() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let runner = makeRunner()
|
let runner = makeRunner()
|
||||||
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||||
@@ -83,13 +80,12 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
|
|
||||||
let url = try await runner.runPrintcal(config: config)
|
let url = try await runner.runPrintcal(config: config)
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "CAL_demo.cal")
|
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal failure throws toolFailed")
|
func testPrintcalFailureThrows() async throws {
|
||||||
func printcalFailureThrows() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
defer { try? FileManager.default.removeItem(at: testRoot) }
|
defer { try? FileManager.default.removeItem(at: testRoot) }
|
||||||
|
|
||||||
@@ -117,9 +113,11 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
outputURL: output
|
outputURL: output
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
|
|
||||||
_ = try await runner.runPrintcal(config: config)
|
_ = try await runner.runPrintcal(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class LogHolder: @unchecked Sendable {
|
final class LogHolder: @unchecked Sendable {
|
||||||
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArgyllRunner colprof")
|
final class ArgyllRunnerColprofTests: XCTestCase {
|
||||||
struct ArgyllRunnerColprofTests {
|
|
||||||
|
|
||||||
@Test("Mock colprof produces .icc")
|
func testColprofProducesIcc() async throws {
|
||||||
func colprofProducesIcc() async throws {
|
|
||||||
let binDir = URL(fileURLWithPath: #filePath)
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
@@ -43,15 +41,14 @@ struct ArgyllRunnerColprofTests {
|
|||||||
holder.append(batch)
|
holder.append(batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "testrun.icc")
|
XCTAssertEqual(url.lastPathComponent, "testrun.icc")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
|
XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failing colprof throws toolFailed with code and logs")
|
func testColprofFailureThrowsToolFailed() async throws {
|
||||||
func colprofFailureThrowsToolFailed() async throws {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
|
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
@@ -72,9 +69,11 @@ struct ArgyllRunnerColprofTests {
|
|||||||
)
|
)
|
||||||
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
|
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tool: "colprof", code: 4, logs: ["colprof broke"])) {
|
|
||||||
try await runner.runColprof(config: config)
|
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 Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Focused contracts for the shared `runStreamingTool` loop (#79).
|
/// Focused contracts for the shared `runStreamingTool` loop (#79).
|
||||||
@@ -7,8 +7,7 @@ import Testing
|
|||||||
/// Every test uses a per-test temporary directory, unique basenames,
|
/// Every test uses a per-test temporary directory, unique basenames,
|
||||||
/// and a fresh `ProcessManager` — no shared UI fixture scripts and no
|
/// and a fresh `ProcessManager` — no shared UI fixture scripts and no
|
||||||
/// process-environment mutation.
|
/// process-environment mutation.
|
||||||
@Suite("ArgyllRunner streaming loop contracts")
|
final class ArgyllRunnerStreamingLoopTests: XCTestCase {
|
||||||
struct ArgyllRunnerStreamingLoopTests {
|
|
||||||
|
|
||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
@@ -30,8 +29,7 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
|
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines")
|
func testNonZeroExitThrowsToolFailed() async throws {
|
||||||
func nonZeroExitThrowsToolFailed() async throws {
|
|
||||||
let dir = try makeTempDir()
|
let dir = try makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
try writeMock("targen", """
|
try writeMock("targen", """
|
||||||
@@ -47,21 +45,20 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await runner.runTargen(config: config)
|
_ = try await runner.runTargen(config: config)
|
||||||
Issue.record("Expected toolFailed")
|
XCTFail("Expected toolFailed")
|
||||||
} catch let error as ArgyllRunnerError {
|
} catch let error as ArgyllRunnerError {
|
||||||
guard case .toolFailed(let tool, let code, let logs) = error else {
|
guard case .toolFailed(let tool, let code, let logs) = error else {
|
||||||
Issue.record("Expected toolFailed, got \(error)")
|
XCTFail("Expected toolFailed, got \(error)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#expect(tool == "targen")
|
XCTAssertEqual(tool, "targen")
|
||||||
#expect(code == 3)
|
XCTAssertEqual(code, 3)
|
||||||
#expect(logs.contains("Generating patches..."))
|
XCTAssertTrue(logs.contains("Generating patches..."))
|
||||||
#expect(logs.contains("targen: too few patches"))
|
XCTAssertTrue(logs.contains("targen: too few patches"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path")
|
func testZeroExitMissingArtefact() async throws {
|
||||||
func zeroExitMissingArtefact() async throws {
|
|
||||||
let dir = try makeTempDir()
|
let dir = try makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
try writeMock("targen", """
|
try writeMock("targen", """
|
||||||
@@ -75,13 +72,14 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
||||||
blackPatches: 4, basename: "gone", workingDirectory: dir)
|
blackPatches: 4, basename: "gone", workingDirectory: dir)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) {
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
try await runner.runTargen(config: config)
|
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 testImmediateExitDeliversLine() async throws {
|
||||||
func immediateExitDeliversLine() async throws {
|
|
||||||
let dir = try makeTempDir()
|
let dir = try makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
try writeMock("targen", """
|
try writeMock("targen", """
|
||||||
@@ -101,13 +99,12 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
let url = try await runner.runTargen(config: config) { batch in
|
let url = try await runner.runTargen(config: config) { batch in
|
||||||
holder.append(batch)
|
holder.append(batch)
|
||||||
}
|
}
|
||||||
#expect(url.lastPathComponent == "quick.ti1")
|
XCTAssertEqual(url.lastPathComponent, "quick.ti1")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
#expect(holder.lines.contains("only line"))
|
XCTAssertTrue(holder.lines.contains("only line"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("colprof unterminated progress fragment reaches onLogBatch before exit")
|
func testColprofPartialLineFlush() async throws {
|
||||||
func colprofPartialLineFlush() async throws {
|
|
||||||
let dir = try makeTempDir()
|
let dir = try makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
// The fragment is printed without a newline, then the mock sleeps
|
// 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
|
let url = try await runner.runColprof(config: config) { batch in
|
||||||
holder.append(batch)
|
holder.append(batch)
|
||||||
}
|
}
|
||||||
#expect(url.lastPathComponent == "frag.icc")
|
XCTAssertEqual(url.lastPathComponent, "frag.icc")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
#expect(holder.lines.contains("Doing gamut mapping"))
|
XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("toolFailed maps each tool to its user-facing description",
|
func testToolDescriptions() {
|
||||||
arguments: [
|
let cases: [(tool: String, expected: String)] = [
|
||||||
(tool: "chartread", expected: "Chartread failed: boom"),
|
(tool: "chartread", expected: "Chartread failed: boom"),
|
||||||
(tool: "average", expected: "Averaging failed: boom"),
|
(tool: "average", expected: "Averaging failed: boom"),
|
||||||
(tool: "colprof", expected: "Profile creation failed: boom"),
|
(tool: "colprof", expected: "Profile creation failed: boom"),
|
||||||
@@ -143,19 +140,19 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
(tool: "applycal", expected: "Apply calibration failed: boom"),
|
(tool: "applycal", expected: "Apply calibration failed: boom"),
|
||||||
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
|
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
|
||||||
(tool: "profcheck", expected: "Profile verification 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"])
|
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 testGenericFallbacks() {
|
||||||
func genericFallbacks() {
|
|
||||||
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
|
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: [])
|
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
|
||||||
#expect(emptyLogs.errorDescription
|
XCTAssertEqual(emptyLogs.errorDescription,
|
||||||
== "Profile creation failed: exited with code 2")
|
"Profile creation failed: exited with code 2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("CalibrationStore")
|
final class CalibrationStoreTests: XCTestCase {
|
||||||
struct CalibrationStoreTests {
|
|
||||||
|
|
||||||
private static let sampleCal = """
|
private static let sampleCal = """
|
||||||
CTI3
|
CTI3
|
||||||
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@Test("Loads metadata and curves from .cal")
|
func testParseCal() async throws {
|
||||||
func parseCal() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("test_\(UUID().uuidString).cal")
|
.appendingPathComponent("test_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
|
|||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
|
|
||||||
let data = await store.data
|
let data = await store.data
|
||||||
#expect(data?.colorRep == "RGB")
|
XCTAssertEqual(data?.colorRep, "RGB")
|
||||||
#expect(data?.descriptor == "Test printer")
|
XCTAssertEqual(data?.descriptor, "Test printer")
|
||||||
#expect(data?.maxTac == 300)
|
XCTAssertEqual(data?.maxTac, 300)
|
||||||
#expect(data?.curves.count == 3)
|
XCTAssertEqual(data?.curves.count, 3)
|
||||||
|
|
||||||
let r = data?.curves.first { $0.channel == "R" }
|
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 testStaleCalibration() async throws {
|
||||||
func staleCalibration() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("stale_\(UUID().uuidString).cal")
|
.appendingPathComponent("stale_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
|
|||||||
let store = CalibrationStore(staleDays: 0)
|
let store = CalibrationStore(staleDays: 0)
|
||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
let stale = await store.isStale(comparedTo: "Other")
|
let stale = await store.isStale(comparedTo: "Other")
|
||||||
#expect(stale == true)
|
XCTAssertEqual(stale, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Printer mismatch is flagged as stale")
|
func testPrinterMismatch() async throws {
|
||||||
func printerMismatch() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
|
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
|
|||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
await store.setPrinterName("Printer A")
|
await store.setPrinterName("Printer A")
|
||||||
let stale = await store.isStale(comparedTo: "Printer B")
|
let stale = await store.isStale(comparedTo: "Printer B")
|
||||||
#expect(stale == true)
|
XCTAssertEqual(stale, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("JSONFileStore")
|
final class JSONFileStoreTests: XCTestCase {
|
||||||
struct JSONFileStoreTests {
|
|
||||||
private func tempURL() -> URL {
|
private func tempURL() -> URL {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Missing file returns default")
|
func testMissingFileDefaults() throws {
|
||||||
func missingFileDefaults() throws {
|
|
||||||
let store = JSONFileStore<AppSettings>(
|
let store = JSONFileStore<AppSettings>(
|
||||||
fileURL: tempURL(),
|
fileURL: tempURL(),
|
||||||
corrupt: .throwCorrupt,
|
corrupt: .throwCorrupt,
|
||||||
defaultValue: { .default }
|
defaultValue: { .default }
|
||||||
)
|
)
|
||||||
#expect(try store.load() == .default)
|
XCTAssertEqual(try store.load(), .default)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
|
func testCorruptDefaults() throws {
|
||||||
func corruptDefaults() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
let store = JSONFileStore<AppSettings>(
|
let store = JSONFileStore<AppSettings>(
|
||||||
@@ -28,13 +25,12 @@ struct JSONFileStoreTests {
|
|||||||
corrupt: .replaceWithDefault,
|
corrupt: .replaceWithDefault,
|
||||||
defaultValue: { .default }
|
defaultValue: { .default }
|
||||||
)
|
)
|
||||||
#expect(try store.load() == .default)
|
XCTAssertEqual(try store.load(), .default)
|
||||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
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 testCorruptThrows() throws {
|
||||||
func corruptThrows() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
let store = JSONFileStore<[Int]>(
|
let store = JSONFileStore<[Int]>(
|
||||||
@@ -42,15 +38,12 @@ struct JSONFileStoreTests {
|
|||||||
corrupt: .throwCorrupt,
|
corrupt: .throwCorrupt,
|
||||||
defaultValue: { [] }
|
defaultValue: { [] }
|
||||||
)
|
)
|
||||||
#expect(throws: DecodingError.self) {
|
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
|
||||||
_ = try store.load()
|
|
||||||
}
|
|
||||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(kept == "not json")
|
XCTAssertEqual(kept, "not json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Pretty sorted keys")
|
func testPrettySorted() throws {
|
||||||
func prettySorted() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = JSONFileStore<AppSettings>(
|
let store = JSONFileStore<AppSettings>(
|
||||||
fileURL: url,
|
fileURL: url,
|
||||||
@@ -59,8 +52,8 @@ struct JSONFileStoreTests {
|
|||||||
)
|
)
|
||||||
try store.save(.default)
|
try store.save(.default)
|
||||||
let text = try String(contentsOf: url, encoding: .utf8)
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(text.contains("\n"))
|
XCTAssertTrue(text.contains("\n"))
|
||||||
#expect(text.contains("\"delta_e_good_max\""))
|
XCTAssertTrue(text.contains("\"delta_e_good_max\""))
|
||||||
// Lexical key sorting: ascending order of top-level keys.
|
// Lexical key sorting: ascending order of top-level keys.
|
||||||
let keys = [
|
let keys = [
|
||||||
"ask_before_overwrite_profile",
|
"ask_before_overwrite_profile",
|
||||||
@@ -75,7 +68,7 @@ struct JSONFileStoreTests {
|
|||||||
var lastIndex = text.startIndex
|
var lastIndex = text.startIndex
|
||||||
for key in keys {
|
for key in keys {
|
||||||
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
|
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
|
||||||
Issue.record("missing or out-of-order key \(key)")
|
XCTFail("missing or out-of-order key \(key)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastIndex = range.upperBound
|
lastIndex = range.upperBound
|
||||||
|
|||||||
@@ -1,116 +1,98 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("ProfilingPreset")
|
final class ProfilingPresetTests: XCTestCase {
|
||||||
struct ProfilingPresetTests {
|
|
||||||
|
|
||||||
@Test("snake_case keys round-trip through Codable")
|
func testRoundTrip() throws {
|
||||||
func roundTrip() throws {
|
|
||||||
var p = PresetCatalog.highQualityCMYK
|
var p = PresetCatalog.highQualityCMYK
|
||||||
p.colprofInputViewingCond = "D50_2"
|
p.colprofInputViewingCond = "D50_2"
|
||||||
let data = try JSONEncoder().encode(p)
|
let data = try JSONEncoder().encode(p)
|
||||||
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
|
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
|
||||||
#expect(decoded == p)
|
XCTAssertEqual(decoded, p)
|
||||||
// Spot-check the wire format.
|
// Spot-check the wire format.
|
||||||
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||||
#expect(obj["colour_space"] as? String == "cmyk")
|
XCTAssertEqual(obj["colour_space"] as? String, "cmyk")
|
||||||
#expect(obj["patch_count"] as? Int == 1500)
|
XCTAssertEqual(obj["patch_count"] as? Int, 1500)
|
||||||
#expect(obj["total_ink_limit"] as? Int == 320)
|
XCTAssertEqual(obj["total_ink_limit"] as? Int, 320)
|
||||||
#expect(obj["bit_depth"] as? Int == 16)
|
XCTAssertEqual(obj["bit_depth"] as? Int, 16)
|
||||||
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2")
|
XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Unknown keys ignored; missing required field fails")
|
func testSchemaTolerance() throws {
|
||||||
func schemaTolerance() throws {
|
|
||||||
let json = """
|
let json = """
|
||||||
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
|
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
|
||||||
"white_patches":1,"black_patches":1,"instrument":"i1",
|
"white_patches":1,"black_patches":1,"instrument":"i1",
|
||||||
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
|
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
|
||||||
""".data(using: .utf8)!
|
""".data(using: .utf8)!
|
||||||
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
|
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
|
||||||
#expect(ok.id == "x")
|
XCTAssertEqual(ok.id, "x")
|
||||||
|
|
||||||
let missing = """
|
let missing = """
|
||||||
{"id":"x","name":"N","colour_space":"rgb"}
|
{"id":"x","name":"N","colour_space":"rgb"}
|
||||||
""".data(using: .utf8)!
|
""".data(using: .utf8)!
|
||||||
#expect(throws: DecodingError.self) {
|
XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
|
||||||
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Validation rejects bad colour space / dpi / bit depth")
|
func testValidation() {
|
||||||
func validation() {
|
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
|
||||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
|
||||||
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
|
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) }
|
||||||
#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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("PresetCatalog")
|
final class PresetCatalogTests: XCTestCase {
|
||||||
struct PresetCatalogTests {
|
|
||||||
|
|
||||||
@Test("Four built-ins with the documented values")
|
func testBuiltIns() {
|
||||||
func builtIns() {
|
XCTAssertEqual(PresetCatalog.builtIns.count, 4)
|
||||||
#expect(PresetCatalog.builtIns.count == 4)
|
|
||||||
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
|
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
|
||||||
|
|
||||||
let std = byID["preset-std-rgb"]!
|
let std = byID["preset-std-rgb"]!
|
||||||
#expect(std.colourSpace == "rgb" && std.patchCount == 800
|
XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800
|
||||||
&& std.pageSize == "A4" && std.bitDepth == 8
|
&& std.pageSize == "A4" && std.bitDepth == 8
|
||||||
&& std.dpi == 300 && std.colprofQuality == "m"
|
&& std.dpi == 300 && std.colprofQuality == "m"
|
||||||
&& std.whitePatches == 4 && std.blackPatches == 4)
|
&& std.whitePatches == 4 && std.blackPatches == 4)
|
||||||
|
|
||||||
let hq = byID["preset-hq-cmyk"]!
|
let hq = byID["preset-hq-cmyk"]!
|
||||||
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
|
XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500
|
||||||
&& hq.pageSize == "A3" && hq.bitDepth == 16
|
&& hq.pageSize == "A3" && hq.bitDepth == 16
|
||||||
&& hq.dpi == 300 && hq.colprofQuality == "h"
|
&& hq.dpi == 300 && hq.colprofQuality == "h"
|
||||||
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
|
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
|
||||||
|
|
||||||
let draft = byID["preset-draft-rgb"]!
|
let draft = byID["preset-draft-rgb"]!
|
||||||
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400
|
XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400
|
||||||
&& draft.pageSize == "A4" && draft.bitDepth == 8
|
&& draft.pageSize == "A4" && draft.bitDepth == 8
|
||||||
&& draft.dpi == 150 && draft.colprofQuality == "l")
|
&& draft.dpi == 150 && draft.colprofQuality == "l")
|
||||||
|
|
||||||
let ultra = byID["preset-ultra-rgb"]!
|
let ultra = byID["preset-ultra-rgb"]!
|
||||||
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
|
XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
|
||||||
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
|
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
|
||||||
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
|
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
|
||||||
&& ultra.ofpsHighQuality == true
|
&& ultra.ofpsHighQuality == true
|
||||||
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
|
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
|
||||||
|
|
||||||
for p in PresetCatalog.builtIns {
|
for p in PresetCatalog.builtIns {
|
||||||
#expect(p.instrument == "i1")
|
XCTAssertEqual(p.instrument, "i1")
|
||||||
#expect(p.colprofFwa == "D50")
|
XCTAssertEqual(p.colprofFwa, "D50")
|
||||||
#expect(p.randomSeed == 1)
|
XCTAssertEqual(p.randomSeed, 1)
|
||||||
#expect(p.noRandomize == false)
|
XCTAssertEqual(p.noRandomize, false)
|
||||||
#expect(p.colprofAlgorithm == "l")
|
XCTAssertEqual(p.colprofAlgorithm, "l")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Custom presets overlay by id; built-ins are not deletable")
|
func testOverlay() {
|
||||||
func overlay() {
|
|
||||||
let custom = ProfilingPreset(
|
let custom = ProfilingPreset(
|
||||||
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
|
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
|
||||||
let all = PresetCatalog.all(custom: [custom])
|
let all = PresetCatalog.all(custom: [custom])
|
||||||
#expect(all.count == 4)
|
XCTAssertEqual(all.count, 4)
|
||||||
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42)
|
XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42)
|
||||||
#expect(PresetCatalog.isBuiltIn("preset-std-rgb"))
|
XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb"))
|
||||||
#expect(!PresetCatalog.isBuiltIn("custom-1"))
|
XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("PresetStore")
|
final class PresetStoreTests: XCTestCase {
|
||||||
struct PresetStoreTests {
|
|
||||||
|
|
||||||
private func tempSettingsURL() throws -> URL {
|
private func tempSettingsURL() throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
@@ -119,58 +101,52 @@ struct PresetStoreTests {
|
|||||||
return dir.appendingPathComponent("settings.json")
|
return dir.appendingPathComponent("settings.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("CRUD + export/import round-trip")
|
func testCrud() throws {
|
||||||
func crud() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||||
|
|
||||||
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
|
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
|
||||||
try store.saveCustom(p)
|
try store.saveCustom(p)
|
||||||
#expect(store.customs().count == 1)
|
XCTAssertEqual(store.customs().count, 1)
|
||||||
#expect(store.all().count == 5)
|
XCTAssertEqual(store.all().count, 5)
|
||||||
|
|
||||||
p.name = "Renamed"
|
p.name = "Renamed"
|
||||||
try store.saveCustom(p)
|
try store.saveCustom(p)
|
||||||
#expect(store.customs().count == 1)
|
XCTAssertEqual(store.customs().count, 1)
|
||||||
#expect(store.customs()[0].name == "Renamed")
|
XCTAssertEqual(store.customs()[0].name, "Renamed")
|
||||||
|
|
||||||
let data = try store.export(p)
|
let data = try store.export(p)
|
||||||
let imported = try store.import(data)
|
let imported = try store.import(data)
|
||||||
#expect(imported.name == "Renamed")
|
XCTAssertEqual(imported.name, "Renamed")
|
||||||
#expect(imported.dpi == 150)
|
XCTAssertEqual(imported.dpi, 150)
|
||||||
|
|
||||||
#expect(try store.deleteCustom(id: "custom-x"))
|
XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
|
||||||
#expect(store.customs().isEmpty)
|
XCTAssertTrue(store.customs().isEmpty)
|
||||||
#expect(try !store.deleteCustom(id: "preset-std-rgb"))
|
XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Import rewrites a built-in id to a fresh custom id")
|
func testImportBuiltinCollision() throws {
|
||||||
func importBuiltinCollision() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||||
let data = try store.export(PresetCatalog.standardRGB)
|
let data = try store.export(PresetCatalog.standardRGB)
|
||||||
let imported = try store.import(data)
|
let imported = try store.import(data)
|
||||||
#expect(imported.id.hasPrefix("custom-"))
|
XCTAssertTrue(imported.id.hasPrefix("custom-"))
|
||||||
#expect(!PresetCatalog.isBuiltIn(imported.id))
|
XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Built-ins are immutable through saveCustom")
|
func testBuiltInImmutable() throws {
|
||||||
func builtInImmutable() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||||
var shadowed = PresetCatalog.standardRGB
|
var shadowed = PresetCatalog.standardRGB
|
||||||
shadowed.name = "Hacked"
|
shadowed.name = "Hacked"
|
||||||
#expect(throws: PresetStore.PresetStoreError.self) {
|
XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
|
||||||
try store.saveCustom(shadowed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("AppSettings preset migration")
|
final class PresetMigrationTests: XCTestCase {
|
||||||
struct PresetMigrationTests {
|
|
||||||
|
|
||||||
private func tempSettingsURL() throws -> URL {
|
private func tempSettingsURL() throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
@@ -179,8 +155,7 @@ struct PresetMigrationTests {
|
|||||||
return dir.appendingPathComponent("settings.json")
|
return dir.appendingPathComponent("settings.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Legacy M1 custom_presets migrate to typed schema")
|
func testLegacyMigration() throws {
|
||||||
func legacyMigration() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let legacy = """
|
let legacy = """
|
||||||
@@ -194,20 +169,19 @@ struct PresetMigrationTests {
|
|||||||
try legacy.write(to: url)
|
try legacy.write(to: url)
|
||||||
|
|
||||||
let settings = SettingsStore(fileURL: url).load()
|
let settings = SettingsStore(fileURL: url).load()
|
||||||
#expect(settings.customPresets.count == 1)
|
XCTAssertEqual(settings.customPresets.count, 1)
|
||||||
let p = settings.customPresets[0]
|
let p = settings.customPresets[0]
|
||||||
#expect(p.name == "Old One")
|
XCTAssertEqual(p.name, "Old One")
|
||||||
#expect(p.id.hasPrefix("custom-0-"))
|
XCTAssertTrue(p.id.hasPrefix("custom-0-"))
|
||||||
#expect(p.colourSpace == "cmyk")
|
XCTAssertEqual(p.colourSpace, "cmyk")
|
||||||
#expect(p.patchCount == 900)
|
XCTAssertEqual(p.patchCount, 900)
|
||||||
#expect(p.dpi == 150)
|
XCTAssertEqual(p.dpi, 150)
|
||||||
#expect(p.bitDepth == 16)
|
XCTAssertEqual(p.bitDepth, 16)
|
||||||
#expect(p.instrument == "p3")
|
XCTAssertEqual(p.instrument, "p3")
|
||||||
#expect(p.pageSize == "A3")
|
XCTAssertEqual(p.pageSize, "A3")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Typed presets load and re-save as the typed schema")
|
func testTypedRoundTrip() throws {
|
||||||
func typedRoundTrip() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
@@ -215,50 +189,45 @@ struct PresetMigrationTests {
|
|||||||
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
|
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
|
||||||
try store.save(s)
|
try store.save(s)
|
||||||
let loaded = store.load()
|
let loaded = store.load()
|
||||||
#expect(loaded.customPresets.first?.patchCount == 700)
|
XCTAssertEqual(loaded.customPresets.first?.patchCount, 700)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Draft preset dpi=150 survives Codable + settings round-trip")
|
func testDraftDPI() throws {
|
||||||
func draftDPI() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||||
let data = try store.export(PresetCatalog.draftRGB)
|
let data = try store.export(PresetCatalog.draftRGB)
|
||||||
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||||
#expect(obj["dpi"] as? Int == 150)
|
XCTAssertEqual(obj["dpi"] as? Int, 150)
|
||||||
let back = try store.import(data)
|
let back = try store.import(data)
|
||||||
#expect(back.dpi == 150)
|
XCTAssertEqual(back.dpi, 150)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("Preset mapping")
|
final class PresetMappingTests: XCTestCase {
|
||||||
struct PresetMappingTests {
|
func testDraftDpi() {
|
||||||
@Test("Draft 150 DPI maps into PrinttargConfig")
|
|
||||||
func draftDpi() {
|
|
||||||
let cfg = PrinttargConfig(
|
let cfg = PrinttargConfig(
|
||||||
preset: PresetCatalog.draftRGB,
|
preset: PresetCatalog.draftRGB,
|
||||||
basename: "t",
|
basename: "t",
|
||||||
workingDirectory: nil,
|
workingDirectory: nil,
|
||||||
calibrationFile: nil
|
calibrationFile: nil
|
||||||
)
|
)
|
||||||
#expect(cfg.dpi == 150)
|
XCTAssertEqual(cfg.dpi, 150)
|
||||||
#expect(cfg.layoutOrder == .deterministic)
|
XCTAssertEqual(cfg.layoutOrder, .deterministic)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Nil optional targen fields stay nil")
|
func testOptionalNil() {
|
||||||
func optionalNil() {
|
|
||||||
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
|
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
|
||||||
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
#expect(cfg.greySteps == nil)
|
XCTAssertNil(cfg.greySteps)
|
||||||
#expect(cfg.singleChannelSteps == nil)
|
XCTAssertNil(cfg.singleChannelSteps)
|
||||||
#expect(cfg.neutralSteps == nil)
|
XCTAssertNil(cfg.neutralSteps)
|
||||||
#expect(cfg.totalInkLimit == nil)
|
XCTAssertNil(cfg.totalInkLimit)
|
||||||
#expect(cfg.darkEmphasis == nil)
|
XCTAssertNil(cfg.darkEmphasis)
|
||||||
#expect(cfg.devicePower == nil)
|
XCTAssertNil(cfg.devicePower)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Custom page and FWA survive a config round-trip")
|
func testRoundTripConfigs() {
|
||||||
func roundTripConfigs() {
|
|
||||||
var preset = PresetCatalog.highQualityCMYK
|
var preset = PresetCatalog.highQualityCMYK
|
||||||
preset.pageSize = "210x297"
|
preset.pageSize = "210x297"
|
||||||
preset.colprofFwa = "D50"
|
preset.colprofFwa = "D50"
|
||||||
@@ -268,9 +237,9 @@ struct PresetMappingTests {
|
|||||||
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
|
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
|
||||||
)
|
)
|
||||||
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
|
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
|
||||||
#expect(printtarg.pageSize == .custom)
|
XCTAssertEqual(printtarg.pageSize, .custom)
|
||||||
#expect(printtarg.customPageWidth == 210)
|
XCTAssertEqual(printtarg.customPageWidth, 210)
|
||||||
#expect(colprof.fwa == "D50")
|
XCTAssertEqual(colprof.fwa, "D50")
|
||||||
let back = ProfilingPreset(
|
let back = ProfilingPreset(
|
||||||
id: preset.id,
|
id: preset.id,
|
||||||
name: preset.name,
|
name: preset.name,
|
||||||
@@ -281,15 +250,14 @@ struct PresetMappingTests {
|
|||||||
calibrationFile: preset.calibrationFile,
|
calibrationFile: preset.calibrationFile,
|
||||||
applyCalibration: preset.applyCalibration
|
applyCalibration: preset.applyCalibration
|
||||||
)
|
)
|
||||||
#expect(back.dpi == preset.dpi)
|
XCTAssertEqual(back.dpi, preset.dpi)
|
||||||
#expect(back.colourSpace == "cmyk")
|
XCTAssertEqual(back.colourSpace, "cmyk")
|
||||||
#expect(back.pageSize == "210x297")
|
XCTAssertEqual(back.pageSize, "210x297")
|
||||||
#expect(back.colprofFwa == "D50")
|
XCTAssertEqual(back.colprofFwa, "D50")
|
||||||
#expect(back.greySteps == nil)
|
XCTAssertNil(back.greySteps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Full preset round-trips through all three configs with every field asserted")
|
func testFullRoundTrip() {
|
||||||
func fullRoundTrip() {
|
|
||||||
let preset = ProfilingPreset(
|
let preset = ProfilingPreset(
|
||||||
id: "custom-full",
|
id: "custom-full",
|
||||||
name: "Full",
|
name: "Full",
|
||||||
@@ -328,21 +296,21 @@ struct PresetMappingTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
|
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
|
||||||
#expect(targen.colourSpace == .cmyk)
|
XCTAssertEqual(targen.colourSpace, .cmyk)
|
||||||
#expect(targen.patchCount == 1500)
|
XCTAssertEqual(targen.patchCount, 1500)
|
||||||
#expect(targen.whitePatches == 6)
|
XCTAssertEqual(targen.whitePatches, 6)
|
||||||
#expect(targen.blackPatches == 8)
|
XCTAssertEqual(targen.blackPatches, 8)
|
||||||
#expect(targen.greySteps == 9)
|
XCTAssertEqual(targen.greySteps, 9)
|
||||||
#expect(targen.singleChannelSteps == 7)
|
XCTAssertEqual(targen.singleChannelSteps, 7)
|
||||||
#expect(targen.neutralSteps == 4)
|
XCTAssertEqual(targen.neutralSteps, 4)
|
||||||
#expect(targen.neutralConcentration == 0.7)
|
XCTAssertEqual(targen.neutralConcentration, 0.7)
|
||||||
#expect(targen.preconditioningProfile == "/tmp/pre.icm")
|
XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm")
|
||||||
#expect(targen.ofpsHighQuality == true)
|
XCTAssertEqual(targen.ofpsHighQuality, true)
|
||||||
#expect(targen.ofpsAdaptation == 0.2)
|
XCTAssertEqual(targen.ofpsAdaptation, 0.2)
|
||||||
#expect(targen.fullSpreadAlgorithm == .uniformRandom)
|
XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom)
|
||||||
#expect(targen.totalInkLimit == 280)
|
XCTAssertEqual(targen.totalInkLimit, 280)
|
||||||
#expect(targen.darkEmphasis == 1.3)
|
XCTAssertEqual(targen.darkEmphasis, 1.3)
|
||||||
#expect(targen.devicePower == 1.2)
|
XCTAssertEqual(targen.devicePower, 1.2)
|
||||||
|
|
||||||
let printtarg = PrinttargConfig(
|
let printtarg = PrinttargConfig(
|
||||||
preset: preset,
|
preset: preset,
|
||||||
@@ -350,25 +318,25 @@ struct PresetMappingTests {
|
|||||||
workingDirectory: nil,
|
workingDirectory: nil,
|
||||||
calibrationFile: preset.calibrationFile
|
calibrationFile: preset.calibrationFile
|
||||||
)
|
)
|
||||||
#expect(printtarg.instrument == .p3)
|
XCTAssertEqual(printtarg.instrument, .p3)
|
||||||
#expect(printtarg.pageSize == .custom)
|
XCTAssertEqual(printtarg.pageSize, .custom)
|
||||||
#expect(printtarg.customPageWidth == 250)
|
XCTAssertEqual(printtarg.customPageWidth, 250)
|
||||||
#expect(printtarg.customPageHeight == 300)
|
XCTAssertEqual(printtarg.customPageHeight, 300)
|
||||||
#expect(printtarg.bitDepth == .sixteen)
|
XCTAssertEqual(printtarg.bitDepth, .sixteen)
|
||||||
#expect(printtarg.dpi == 360)
|
XCTAssertEqual(printtarg.dpi, 360)
|
||||||
#expect(printtarg.layoutOrder == .customSeed)
|
XCTAssertEqual(printtarg.layoutOrder, .customSeed)
|
||||||
#expect(printtarg.customSeed == 42)
|
XCTAssertEqual(printtarg.customSeed, 42)
|
||||||
#expect(printtarg.calibrationFile == "/tmp/a.cal")
|
XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal")
|
||||||
|
|
||||||
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
|
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
|
||||||
#expect(colprof.algorithm == "x")
|
XCTAssertEqual(colprof.algorithm, "x")
|
||||||
#expect(colprof.quality == "u")
|
XCTAssertEqual(colprof.quality, "u")
|
||||||
#expect(colprof.intent == "p")
|
XCTAssertEqual(colprof.intent, "p")
|
||||||
#expect(colprof.fwa == "D65")
|
XCTAssertEqual(colprof.fwa, "D65")
|
||||||
#expect(colprof.illuminant == "D65")
|
XCTAssertEqual(colprof.illuminant, "D65")
|
||||||
#expect(colprof.observer == "1931_2")
|
XCTAssertEqual(colprof.observer, "1931_2")
|
||||||
#expect(colprof.inputViewingCond == "D50_2")
|
XCTAssertEqual(colprof.inputViewingCond, "D50_2")
|
||||||
#expect(colprof.outputViewingCond == "D65_2")
|
XCTAssertEqual(colprof.outputViewingCond, "D65_2")
|
||||||
|
|
||||||
let back = ProfilingPreset(
|
let back = ProfilingPreset(
|
||||||
id: preset.id,
|
id: preset.id,
|
||||||
@@ -380,117 +348,126 @@ struct PresetMappingTests {
|
|||||||
calibrationFile: preset.calibrationFile,
|
calibrationFile: preset.calibrationFile,
|
||||||
applyCalibration: preset.applyCalibration
|
applyCalibration: preset.applyCalibration
|
||||||
)
|
)
|
||||||
#expect(back == preset)
|
XCTAssertEqual(back, preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Every full-spread algorithm round-trips", arguments: [
|
func testFullSpreadAlgorithms() {
|
||||||
("ofps", FullSpreadAlgorithm.ofps),
|
let cases: [(String, FullSpreadAlgorithm)] = [
|
||||||
("t", .target),
|
("ofps", .ofps),
|
||||||
("r", .random),
|
("t", .target),
|
||||||
("R", .uniformRandom),
|
("r", .random),
|
||||||
("q", .quasiRandom),
|
("R", .uniformRandom),
|
||||||
("Q", .uniformQuasiRandom),
|
("q", .quasiRandom),
|
||||||
("i", .invertedQuasiRandom),
|
("Q", .uniformQuasiRandom),
|
||||||
("I", .invertedUniformQuasiRandom)
|
("i", .invertedQuasiRandom),
|
||||||
])
|
("I", .invertedUniformQuasiRandom)
|
||||||
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) {
|
]
|
||||||
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
for (value, expected) in cases {
|
||||||
preset.fullSpreadAlgorithm = value
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
preset.fullSpreadAlgorithm = value
|
||||||
if expected == .ofps {
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
// ofps is the default — no flag emitted, stored value is nil.
|
if expected == .ofps {
|
||||||
#expect(cfg.fullSpreadAlgorithm == nil)
|
// ofps is the default — no flag emitted, stored value is nil.
|
||||||
} else {
|
XCTAssertNil(cfg.fullSpreadAlgorithm)
|
||||||
#expect(cfg.fullSpreadAlgorithm == expected)
|
} else {
|
||||||
|
XCTAssertEqual(cfg.fullSpreadAlgorithm, expected)
|
||||||
|
}
|
||||||
|
let back = ProfilingPreset(
|
||||||
|
id: "x", name: "n", description: "",
|
||||||
|
targen: cfg,
|
||||||
|
printtarg: PrinttargConfig(
|
||||||
|
preset: preset, basename: "t",
|
||||||
|
workingDirectory: nil, calibrationFile: nil
|
||||||
|
),
|
||||||
|
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
|
||||||
|
calibrationFile: nil,
|
||||||
|
applyCalibration: nil
|
||||||
|
)
|
||||||
|
XCTAssertEqual(back.fullSpreadAlgorithm, value)
|
||||||
}
|
}
|
||||||
let back = ProfilingPreset(
|
|
||||||
id: "x", name: "n", description: "",
|
|
||||||
targen: cfg,
|
|
||||||
printtarg: PrinttargConfig(
|
|
||||||
preset: preset, basename: "t",
|
|
||||||
workingDirectory: nil, calibrationFile: nil
|
|
||||||
),
|
|
||||||
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
|
|
||||||
calibrationFile: nil,
|
|
||||||
applyCalibration: nil
|
|
||||||
)
|
|
||||||
#expect(back.fullSpreadAlgorithm == value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil")
|
func testOfpsHighQualityFalse() {
|
||||||
func ofpsHighQualityFalse() {
|
|
||||||
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
preset.ofpsHighQuality = false
|
preset.ofpsHighQuality = false
|
||||||
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
#expect(cfg.ofpsHighQuality == false)
|
XCTAssertEqual(cfg.ofpsHighQuality, false)
|
||||||
|
|
||||||
preset.ofpsHighQuality = nil
|
preset.ofpsHighQuality = nil
|
||||||
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
#expect(nilCfg.ofpsHighQuality == nil)
|
XCTAssertNil(nilCfg.ofpsHighQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("noRandomize/seed layout mapping rules", arguments: [
|
func testLayoutMapping() {
|
||||||
(true, nil, LayoutOrder.raster, 1),
|
let cases: [(Bool?, Int?, LayoutOrder, Int)] = [
|
||||||
(true, 7, .raster, 7),
|
(true, nil, .raster, 1),
|
||||||
(false, nil, .deterministic, 1),
|
(true, 7, .raster, 7),
|
||||||
(false, 1, .deterministic, 1),
|
(false, nil, .deterministic, 1),
|
||||||
(nil, 1, .deterministic, 1),
|
(false, 1, .deterministic, 1),
|
||||||
(false, 5, .customSeed, 5)
|
(nil, 1, .deterministic, 1),
|
||||||
] as [(Bool?, Int?, LayoutOrder, Int)])
|
(false, 5, .customSeed, 5)
|
||||||
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) {
|
]
|
||||||
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
for (noRandomize, seed, layout, expectedSeed) in cases {
|
||||||
preset.noRandomize = noRandomize
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
preset.randomSeed = seed
|
preset.noRandomize = noRandomize
|
||||||
let cfg = PrinttargConfig(
|
preset.randomSeed = seed
|
||||||
preset: preset, basename: "t",
|
let cfg = PrinttargConfig(
|
||||||
workingDirectory: nil, calibrationFile: nil
|
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: [
|
func testCustomPageFallback() {
|
||||||
("250x300", PageSize.custom, 250.0, 300.0),
|
let cases: [(String, PageSize, Double, Double)] = [
|
||||||
("50x50", .custom, 50.0, 50.0),
|
("250x300", .custom, 250.0, 300.0),
|
||||||
("foo", .a4, 210.0, 297.0),
|
("50x50", .custom, 50.0, 50.0),
|
||||||
("30x40", .a4, 210.0, 297.0),
|
("foo", .a4, 210.0, 297.0),
|
||||||
("210x", .a4, 210.0, 297.0)
|
("30x40", .a4, 210.0, 297.0),
|
||||||
] as [(String, PageSize, Double, Double)])
|
("210x", .a4, 210.0, 297.0)
|
||||||
func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) {
|
]
|
||||||
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
for (raw, page, w, h) in cases {
|
||||||
preset.pageSize = raw
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
let cfg = PrinttargConfig(
|
preset.pageSize = raw
|
||||||
preset: preset, basename: "t",
|
let cfg = PrinttargConfig(
|
||||||
workingDirectory: nil, calibrationFile: nil
|
preset: preset, basename: "t",
|
||||||
)
|
workingDirectory: nil, calibrationFile: nil
|
||||||
#expect(cfg.pageSize == page)
|
)
|
||||||
#expect(cfg.customPageWidth == w)
|
XCTAssertEqual(cfg.pageSize, page)
|
||||||
#expect(cfg.customPageHeight == h)
|
XCTAssertEqual(cfg.customPageWidth, w)
|
||||||
|
XCTAssertEqual(cfg.customPageHeight, h)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("FWA preset value → selection matrix", arguments: [
|
func testFwaToSelection() {
|
||||||
(nil, ColprofFwaSelection.none),
|
let cases: [(String?, ColprofFwaSelection)] = [
|
||||||
("none", .none),
|
(nil, .none),
|
||||||
("NONE", .none),
|
("none", .none),
|
||||||
("", .empty),
|
("NONE", .none),
|
||||||
("D50", .D50),
|
("", .empty),
|
||||||
("d50", .D50),
|
("D50", .D50),
|
||||||
("D65", .D65),
|
("d50", .D50),
|
||||||
("d65", .D65),
|
("D65", .D65),
|
||||||
("/tmp/fwa.sp", .custom)
|
("d65", .D65),
|
||||||
] as [(String?, ColprofFwaSelection)])
|
("/tmp/fwa.sp", .custom)
|
||||||
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: [
|
func testFwaToPresetValue() {
|
||||||
(ColprofFwaSelection.none, nil),
|
let cases: [(ColprofFwaSelection, String?)] = [
|
||||||
(.empty, ""),
|
(.none, nil),
|
||||||
(.D50, "D50"),
|
(.empty, ""),
|
||||||
(.D65, "D65"),
|
(.D50, "D50"),
|
||||||
(.custom, "/tmp/fwa.sp")
|
(.D65, "D65"),
|
||||||
] as [(ColprofFwaSelection, String?)])
|
(.custom, "/tmp/fwa.sp")
|
||||||
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 Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Issue #82 — preset application through the live view models, under an
|
/// Issue #82 — preset application through the live view models, under an
|
||||||
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
|
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
|
||||||
@Suite("PresetViewModelMapping")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PresetViewModelMappingTests {
|
final class PresetViewModelMappingTests: XCTestCase {
|
||||||
|
|
||||||
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
|
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
|
||||||
let env = try TestAppEnvironment.make()
|
let env = try TestAppEnvironment.make()
|
||||||
return (env, TargetWorkflowViewModel(environment: env.environment))
|
return (env, TargetWorkflowViewModel(environment: env.environment))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path")
|
func testNilFwaClearsCustomPath() throws {
|
||||||
func nilFwaClearsCustomPath() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -24,18 +22,17 @@ struct PresetViewModelMappingTests {
|
|||||||
colprofFwa: "/tmp/fwa.sp"
|
colprofFwa: "/tmp/fwa.sp"
|
||||||
)
|
)
|
||||||
vm.applyPreset(customPreset)
|
vm.applyPreset(customPreset)
|
||||||
#expect(vm.profile.fwaSelection == .custom)
|
XCTAssertEqual(vm.profile.fwaSelection, .custom)
|
||||||
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp")
|
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp")
|
||||||
|
|
||||||
customPreset.colprofFwa = nil
|
customPreset.colprofFwa = nil
|
||||||
vm.applyPreset(customPreset)
|
vm.applyPreset(customPreset)
|
||||||
#expect(vm.profile.fwaSelection == .none)
|
XCTAssertEqual(vm.profile.fwaSelection, .none)
|
||||||
#expect(vm.profile.fwaCustomPath == "")
|
XCTAssertEqual(vm.profile.fwaCustomPath, "")
|
||||||
#expect(vm.profile.fwaValue == nil)
|
XCTAssertNil(vm.profile.fwaValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Custom FWA preset path survives the round-trip to colprof_fwa")
|
func testCustomFwaRoundTrip() throws {
|
||||||
func customFwaRoundTrip() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -44,13 +41,12 @@ struct PresetViewModelMappingTests {
|
|||||||
colprofFwa: "/tmp/other.sp"
|
colprofFwa: "/tmp/other.sp"
|
||||||
)
|
)
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
#expect(vm.profile.fwaSelection == .custom)
|
XCTAssertEqual(vm.profile.fwaSelection, .custom)
|
||||||
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp")
|
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp")
|
||||||
#expect(vm.profile.fwaValue == "/tmp/other.sp")
|
XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Preset calibration reaches Stage 2 instead of stale live state")
|
func testPresetCalibrationReachesStage2() throws {
|
||||||
func presetCalibrationReachesStage2() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -65,13 +61,12 @@ struct PresetViewModelMappingTests {
|
|||||||
)
|
)
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
#expect(vm.profile.applyCalibration)
|
XCTAssertTrue(vm.profile.applyCalibration)
|
||||||
#expect(vm.profile.calibrationFile == "/tmp/preset.cal")
|
XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal")
|
||||||
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal")
|
XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Preset with calibration disabled clears Stage 2 calibration")
|
func testDisabledCalibrationClearsStage2() throws {
|
||||||
func disabledCalibrationClearsStage2() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -85,12 +80,11 @@ struct PresetViewModelMappingTests {
|
|||||||
)
|
)
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
#expect(!vm.profile.applyCalibration)
|
XCTAssertFalse(vm.profile.applyCalibration)
|
||||||
#expect(vm.buildPrinttargConfig().calibrationFile == nil)
|
XCTAssertNil(vm.buildPrinttargConfig().calibrationFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Preset Stage 1/2 form fields apply to the live form")
|
func testFormFieldsApply() throws {
|
||||||
func formFieldsApply() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -106,22 +100,22 @@ struct PresetViewModelMappingTests {
|
|||||||
)
|
)
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
#expect(vm.colourSpace == .cmyk)
|
XCTAssertEqual(vm.colourSpace, .cmyk)
|
||||||
#expect(vm.effectivePatchCount == 1500)
|
XCTAssertEqual(vm.effectivePatchCount, 1500)
|
||||||
#expect(vm.whitePatches == 6)
|
XCTAssertEqual(vm.whitePatches, 6)
|
||||||
#expect(vm.blackPatches == 8)
|
XCTAssertEqual(vm.blackPatches, 8)
|
||||||
#expect(vm.greyStepsEnabled && vm.greySteps == 9)
|
XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9)
|
||||||
#expect(vm.algorithm == .random)
|
XCTAssertEqual(vm.algorithm, .random)
|
||||||
#expect(vm.tiffDpi == 150)
|
XCTAssertEqual(vm.tiffDpi, 150)
|
||||||
#expect(vm.pageSize == .custom)
|
XCTAssertEqual(vm.pageSize, .custom)
|
||||||
#expect(vm.customPageW == 250 && vm.customPageH == 300)
|
XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300)
|
||||||
#expect(vm.selectedPresetID == "c-form")
|
XCTAssertEqual(vm.selectedPresetID, "c-form")
|
||||||
|
|
||||||
// Disabled advanced controls stay nil in the snapshot, not
|
// Disabled advanced controls stay nil in the snapshot, not
|
||||||
// numeric sentinels.
|
// numeric sentinels.
|
||||||
preset.greySteps = nil
|
preset.greySteps = nil
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
#expect(!vm.greyStepsEnabled)
|
XCTAssertFalse(vm.greyStepsEnabled)
|
||||||
#expect(vm.buildTargenConfig().greySteps == nil)
|
XCTAssertNil(vm.buildTargenConfig().greySteps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Issue 13 — panel outcome mapping (cancel → nil, ok → result).
|
/// Issue 13 — panel outcome mapping (cancel → nil, ok → result).
|
||||||
/// The real `NSPrintPanel` is never run in tests; these exercise the
|
/// The real `NSPrintPanel` is never run in tests; these exercise the
|
||||||
/// `UITestHooks` seam the UI tests rely on.
|
/// `UITestHooks` seam the UI tests rely on.
|
||||||
@Suite("PrintPanelStub")
|
final class PrintPanelStubTests: XCTestCase {
|
||||||
struct PrintPanelStubTests {
|
|
||||||
|
|
||||||
private func withEnv(
|
private func withEnv(
|
||||||
_ vars: [String: String?],
|
_ vars: [String: String?],
|
||||||
@@ -28,19 +27,17 @@ struct PrintPanelStubTests {
|
|||||||
try body()
|
try body()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Cancel returns nil — not an error")
|
func testCancelIsNil() throws {
|
||||||
func cancelIsNil() throws {
|
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_PRINT_PANEL": "cancel",
|
"ICCERY_TEST_PRINT_PANEL": "cancel",
|
||||||
]) {
|
]) {
|
||||||
#expect(UITestHooks.printPanelStubbed)
|
XCTAssertTrue(UITestHooks.printPanelStubbed)
|
||||||
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
|
XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("OK returns captured options + selected printer")
|
func testOkResult() throws {
|
||||||
func okResult() throws {
|
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
@@ -48,15 +45,14 @@ struct PrintPanelStubTests {
|
|||||||
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
||||||
]) {
|
]) {
|
||||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||||
#expect(result?.selectedPrinter == "Other_Queue")
|
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
|
||||||
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
|
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
|
||||||
#expect(result?.options.mediaType == "Photo")
|
XCTAssertEqual(result?.options.mediaType, "Photo")
|
||||||
#expect(result?.options.ppdUncorrectedPassthrough == true)
|
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("OK defaults selected printer to the opened queue")
|
func testOkDefaultsPrinter() throws {
|
||||||
func okDefaultsPrinter() throws {
|
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
@@ -64,8 +60,8 @@ struct PrintPanelStubTests {
|
|||||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||||
]) {
|
]) {
|
||||||
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
|
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
|
||||||
#expect(result?.selectedPrinter == "My_Queue")
|
XCTAssertEqual(result?.selectedPrinter, "My_Queue")
|
||||||
#expect(result?.options.cupsOptions == nil)
|
XCTAssertNil(result?.options.cupsOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Testing
|
|
||||||
import XCTest
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@@ -257,8 +256,7 @@ final class PrinttargManifestTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArgyllRunner Printtarg")
|
final class ArgyllRunnerPrinttargTests: XCTestCase {
|
||||||
struct ArgyllRunnerPrinttargTests {
|
|
||||||
|
|
||||||
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
|
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
@@ -310,8 +308,7 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
try Data(bytes).write(to: url)
|
try Data(bytes).write(to: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Successful printtarg emits .ti2 + manifest + PNG previews")
|
func testSuccess() async throws {
|
||||||
func success() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
last=""
|
last=""
|
||||||
@@ -330,18 +327,17 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
processManager: ProcessManager(), binaryResolver: resolver)
|
processManager: ProcessManager(), binaryResolver: resolver)
|
||||||
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
|
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
|
||||||
let result = try await runner.runPrinttarg(config: config)
|
let result = try await runner.runPrinttarg(config: config)
|
||||||
#expect(result.ti2URL.lastPathComponent == "pt.ti2")
|
XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2")
|
||||||
#expect(result.manifest.pages.count == 1)
|
XCTAssertEqual(result.manifest.pages.count, 1)
|
||||||
#expect(result.pages.count == 1)
|
XCTAssertEqual(result.pages.count, 1)
|
||||||
let png = result.pages[0].previewPNG
|
let png = result.pages[0].previewPNG
|
||||||
#expect(png != nil)
|
XCTAssertNotNil(png)
|
||||||
if let 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 testFailure() async throws {
|
||||||
func failure() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
echo "oops" >&2
|
echo "oops" >&2
|
||||||
@@ -351,15 +347,16 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tool: "printtarg", code: 3, logs: ["oops"])) {
|
|
||||||
try await runner.runPrinttarg(
|
try await runner.runPrinttarg(
|
||||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "printtarg", code: 3, logs: ["oops"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Exit 0 without manifest → malformedManifest")
|
func testNoManifest() async throws {
|
||||||
func noManifest() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
last=""
|
last=""
|
||||||
@@ -372,14 +369,13 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||||
await #expect(throws: ArgyllRunnerError.self) {
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
try await runner.runPrinttarg(
|
try await runner.runPrinttarg(
|
||||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Exit 0 without .ti2 → missingArtefact")
|
func testNoTi2() async throws {
|
||||||
func noTi2() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
|
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
|
||||||
@@ -389,14 +385,13 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||||
await #expect(throws: ArgyllRunnerError.self) {
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
try await runner.runPrinttarg(
|
try await runner.runPrinttarg(
|
||||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Deterministic config produces byte-identical .ti2")
|
func testDeterminism() async throws {
|
||||||
func determinism() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
last=""
|
last=""
|
||||||
@@ -416,6 +411,6 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
config: PrinttargConfig(basename: "b", workingDirectory: dir))
|
config: PrinttargConfig(basename: "b", workingDirectory: dir))
|
||||||
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
|
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
|
||||||
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
|
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
|
||||||
#expect(d1 == d2)
|
XCTAssertEqual(d1, d2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Testing
|
|
||||||
import XCTest
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
||||||
/// scripts written to a temp dir — no resource bundling required.
|
/// scripts written to a temp dir — no resource bundling required.
|
||||||
@Suite("ProcessManager", .serialized)
|
/// XCTest executes test methods serially by default.
|
||||||
struct ProcessManagerTests {
|
final class ProcessManagerTests: XCTestCase {
|
||||||
|
|
||||||
// MARK: - Fixture plumbing
|
// MARK: - Fixture plumbing
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ struct ProcessManagerTests {
|
|||||||
if box.finish() { cont.resume(returning: box.events) }
|
if box.finish() { cont.resume(returning: box.events) }
|
||||||
}
|
}
|
||||||
Task {
|
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) }
|
if box.finish() { cont.resume(returning: box.events) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +84,7 @@ struct ProcessManagerTests {
|
|||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if exitCount(in: box) > 0 { return true }
|
if exitCount(in: box) > 0 { return true }
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -94,7 +93,7 @@ struct ProcessManagerTests {
|
|||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if FileManager.default.fileExists(atPath: url.path) { return true }
|
if FileManager.default.fileExists(atPath: url.path) { return true }
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -107,14 +106,14 @@ struct ProcessManagerTests {
|
|||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if await manager.isRunning(id) { return true }
|
if await manager.isRunning(id) { return true }
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
@Test func streamsStdoutAndEmitsExit() async throws {
|
func testStreamsStdoutAndEmitsExit() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
|
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
|
||||||
async let events = collect(pm, id: "t1")
|
async let events = collect(pm, id: "t1")
|
||||||
@@ -123,21 +122,21 @@ struct ProcessManagerTests {
|
|||||||
let lines = evs.compactMap { e -> String? in
|
let lines = evs.compactMap { e -> String? in
|
||||||
if case .stdout(_, let l) = e { return l }; return nil
|
if case .stdout(_, let l) = e { return l }; return nil
|
||||||
}
|
}
|
||||||
#expect(lines == ["hello", "world"])
|
XCTAssertEqual(lines, ["hello", "world"])
|
||||||
#expect(evs.contains(.exit(id: "t1", code: 0)))
|
XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func routesStderrSeparately() async throws {
|
func testRoutesStderrSeparately() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
|
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
|
||||||
async let evs = collect(pm, id: "t2")
|
async let evs = collect(pm, id: "t2")
|
||||||
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
|
||||||
let events = await evs
|
let events = await evs
|
||||||
#expect(events.contains(.stdout(id: "t2", line: "out")))
|
XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out")))
|
||||||
#expect(events.contains(.stderr(id: "t2", line: "oops")))
|
XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stripsRowColorsJSONPrefix() async throws {
|
func testStripsRowColorsJSONPrefix() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script(
|
let bin = try script(
|
||||||
"rows.sh",
|
"rows.sh",
|
||||||
@@ -150,21 +149,22 @@ struct ProcessManagerTests {
|
|||||||
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#expect(rows == ["{\"row\":1}"])
|
XCTAssertEqual(rows, ["{\"row\":1}"])
|
||||||
#expect(events.contains(.stdout(id: "t3", line: "plain")))
|
XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain")))
|
||||||
// Prefixed lines must not leak into stdout.
|
// 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 pm = ProcessManager()
|
||||||
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
|
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
|
||||||
async let evs = collect(pm, id: "t4")
|
async let evs = collect(pm, id: "t4")
|
||||||
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
|
||||||
#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()
|
let pm = ProcessManager()
|
||||||
// Read two lines then exit naturally — a killed sh would lose its
|
// Read two lines then exit naturally — a killed sh would lose its
|
||||||
// buffered stdio output, which is exactly the chartread pattern.
|
// buffered stdio output, which is exactly the chartread pattern.
|
||||||
@@ -177,21 +177,23 @@ struct ProcessManagerTests {
|
|||||||
try await pm.sendStdin(id: "t5", text: " \n")
|
try await pm.sendStdin(id: "t5", text: " \n")
|
||||||
try await pm.sendStdin(id: "t5", text: "d\n")
|
try await pm.sendStdin(id: "t5", text: "d\n")
|
||||||
let events = await evs
|
let events = await evs
|
||||||
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
|
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: ")))
|
||||||
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
|
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func duplicateIDRejected() async throws {
|
func testDuplicateIDRejected() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
|
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||||
await #expect(throws: ProcessError.duplicateID("t6")) {
|
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||||
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .duplicateID("t6"))
|
||||||
}
|
}
|
||||||
await pm.kill(id: "t6")
|
await pm.kill(id: "t6")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func killEmitsExitAndClosesStdin() async throws {
|
func testKillEmitsExitAndClosesStdin() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
||||||
async let evs = collect(pm, id: "t7")
|
async let evs = collect(pm, id: "t7")
|
||||||
@@ -200,49 +202,51 @@ struct ProcessManagerTests {
|
|||||||
let events = await evs
|
let events = await evs
|
||||||
// exit emitted exactly once
|
// exit emitted exactly once
|
||||||
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
||||||
#expect(exits.count == 1)
|
XCTAssertEqual(exits.count, 1)
|
||||||
await #expect(throws: ProcessError.unknownID("t7")) {
|
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||||
try await pm.sendStdin(id: "t7", text: "d\n")
|
try await pm.sendStdin(id: "t7", text: "d\n")
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .unknownID("t7"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func killAllCountsSignaled() async throws {
|
func testKillAllCountsSignaled() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
|
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
|
||||||
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
|
||||||
let count = await pm.killAll()
|
let count = await pm.killAll()
|
||||||
#expect(count == 2)
|
XCTAssertEqual(count, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunReturnsBothStreams() async throws {
|
func testCapturedRunReturnsBothStreams() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
|
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
|
||||||
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
|
||||||
#expect(result.stdout.contains("out-data"))
|
XCTAssertTrue(result.stdout.contains("out-data"))
|
||||||
#expect(result.stderr.contains("err-data"))
|
XCTAssertTrue(result.stderr.contains("err-data"))
|
||||||
#expect(result.exitCode == 3)
|
XCTAssertEqual(result.exitCode, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunFastExit() async throws {
|
func testCapturedRunFastExit() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
|
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
|
||||||
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
|
||||||
#expect(result.exitCode == 7)
|
XCTAssertEqual(result.exitCode, 7)
|
||||||
#expect(result.stdout == "")
|
XCTAssertEqual(result.stdout, "")
|
||||||
#expect(result.stderr == "")
|
XCTAssertEqual(result.stderr, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunStderrOnly() async throws {
|
func testCapturedRunStderrOnly() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
|
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
|
||||||
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
|
||||||
#expect(result.exitCode == 1)
|
XCTAssertEqual(result.exitCode, 1)
|
||||||
#expect(result.stdout == "")
|
XCTAssertEqual(result.stdout, "")
|
||||||
#expect(result.stderr.contains("mock lp failure"))
|
XCTAssertTrue(result.stderr.contains("mock lp failure"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
||||||
let bin = try script(
|
let bin = try script(
|
||||||
@@ -250,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"
|
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
|
||||||
)
|
)
|
||||||
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
|
||||||
#expect(result.stdout.contains("out-4999"))
|
XCTAssertTrue(result.stdout.contains("out-4999"))
|
||||||
#expect(result.stderr.contains("err-4999"))
|
XCTAssertTrue(result.stderr.contains("err-4999"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func argyllEnvVarIsSet() async throws {
|
func testArgyllEnvVarIsSet() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
|
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
|
||||||
async let evs = collect(pm, id: "t10")
|
async let evs = collect(pm, id: "t10")
|
||||||
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
|
||||||
#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()
|
let pm = ProcessManager()
|
||||||
await #expect(throws: ProcessError.unknownID("nope")) {
|
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||||
try await pm.sendStdin(id: "nope", text: "d\n")
|
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .unknownID("nope"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
|
func testExplicitPartialFlushEmitsRowColorsJSON() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let marker = Self.fixtureDir
|
let marker = Self.fixtureDir
|
||||||
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
||||||
@@ -280,7 +287,8 @@ struct ProcessManagerTests {
|
|||||||
let box = Box()
|
let box = Box()
|
||||||
let observer = observe(pm, id: "t11", into: box)
|
let observer = observe(pm, id: "t11", into: box)
|
||||||
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
|
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
|
||||||
#expect(await waitForFile(marker))
|
let markerReady = await waitForFile(marker)
|
||||||
|
XCTAssertTrue(markerReady)
|
||||||
// Retry the flush so the pipe-ingest task can win the actor race
|
// Retry the flush so the pipe-ingest task can win the actor race
|
||||||
// on a loaded host; the first successful flush emits the row.
|
// on a loaded host; the first successful flush emits the row.
|
||||||
var flushed = false
|
var flushed = false
|
||||||
@@ -290,24 +298,25 @@ struct ProcessManagerTests {
|
|||||||
flushed = true
|
flushed = true
|
||||||
break
|
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")
|
await pm.kill(id: "t11")
|
||||||
#expect(await waitForExit(in: box))
|
let sawExit = await waitForExit(in: box)
|
||||||
|
XCTAssertTrue(sawExit)
|
||||||
observer.cancel()
|
observer.cancel()
|
||||||
let events = box.events
|
let events = box.events
|
||||||
let rows = events.compactMap { e -> String? in
|
let rows = events.compactMap { e -> String? in
|
||||||
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#expect(rows == ["{\"row\":9}"])
|
XCTAssertEqual(rows, ["{\"row\":9}"])
|
||||||
// Prefixed tails must not leak into stdout, even via finalize.
|
// Prefixed tails must not leak into stdout, even via finalize.
|
||||||
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
|
XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
|
||||||
#expect(exitCount(in: box) == 1)
|
XCTAssertEqual(exitCount(in: box), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
|
func testUnterminatedRowTailFinalizesAsJSONRow() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script(
|
let bin = try script(
|
||||||
"row-tail.sh",
|
"row-tail.sh",
|
||||||
@@ -316,68 +325,70 @@ struct ProcessManagerTests {
|
|||||||
let box = Box()
|
let box = Box()
|
||||||
let observer = observe(pm, id: "t12", into: box)
|
let observer = observe(pm, id: "t12", into: box)
|
||||||
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
|
||||||
#expect(await waitForExit(in: box))
|
let sawExit = await waitForExit(in: box)
|
||||||
|
XCTAssertTrue(sawExit)
|
||||||
observer.cancel()
|
observer.cancel()
|
||||||
let events = box.events
|
let events = box.events
|
||||||
let rows = events.compactMap { e -> String? in
|
let rows = events.compactMap { e -> String? in
|
||||||
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#expect(rows == ["{\"row\":42}"])
|
XCTAssertEqual(rows, ["{\"row\":42}"])
|
||||||
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
|
XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
|
||||||
let rowIndex = events.firstIndex {
|
let rowIndex = events.firstIndex {
|
||||||
if case .jsonRow = $0 { return true }; return false
|
if case .jsonRow = $0 { return true }; return false
|
||||||
}
|
}
|
||||||
let exitIndexes = events.indices.filter {
|
let exitIndexes = events.indices.filter {
|
||||||
if case .exit = events[$0] { return true }; return false
|
if case .exit = events[$0] { return true }; return false
|
||||||
}
|
}
|
||||||
#expect(exitIndexes.count == 1)
|
XCTAssertEqual(exitIndexes.count, 1)
|
||||||
if let rowIndex, let exitIndex = exitIndexes.first {
|
if let rowIndex, let exitIndex = exitIndexes.first {
|
||||||
#expect(rowIndex < exitIndex)
|
XCTAssertTrue(rowIndex < exitIndex)
|
||||||
} else {
|
} 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 pm = ProcessManager()
|
||||||
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
|
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
|
||||||
let box = Box()
|
let box = Box()
|
||||||
let observer = observe(pm, id: "t13", into: box)
|
let observer = observe(pm, id: "t13", into: box)
|
||||||
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
|
||||||
#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
|
// The grace window must outlast the 2 s finalize watchdog so a
|
||||||
// duplicate emission from it would be observed.
|
// duplicate emission from it would be observed.
|
||||||
try await Task.sleep(for: .milliseconds(2500))
|
try await Task.sleep(nanoseconds: 2_500_000_000)
|
||||||
observer.cancel()
|
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 pm = ProcessManager()
|
||||||
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
|
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
|
||||||
let box = Box()
|
let box = Box()
|
||||||
let observer = observe(pm, id: "t14", into: box)
|
let observer = observe(pm, id: "t14", into: box)
|
||||||
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
|
||||||
#expect(result.exitCode == 7)
|
XCTAssertEqual(result.exitCode, 7)
|
||||||
// Both the termination handler and the waitUntilExit watchdog
|
// Both the termination handler and the waitUntilExit watchdog
|
||||||
// resume the same box; give the slower path time to fire.
|
// resume the same box; give the slower path time to fire.
|
||||||
try await Task.sleep(for: .milliseconds(500))
|
try await Task.sleep(nanoseconds: 500_000_000)
|
||||||
observer.cancel()
|
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 pm = ProcessManager()
|
||||||
let bin = try script(
|
let bin = try script(
|
||||||
"cap-env.sh",
|
"cap-env.sh",
|
||||||
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
|
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
|
||||||
)
|
)
|
||||||
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
|
||||||
#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 pm = ProcessManager()
|
||||||
let marker = Self.fixtureDir
|
let marker = Self.fixtureDir
|
||||||
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
||||||
@@ -391,21 +402,29 @@ struct ProcessManagerTests {
|
|||||||
let capTask = Task {
|
let capTask = Task {
|
||||||
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
|
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
|
||||||
}
|
}
|
||||||
#expect(await waitForFile(marker))
|
let markerReady = await waitForFile(marker)
|
||||||
#expect(await waitForRunning(pm, id: "t16"))
|
XCTAssertTrue(markerReady)
|
||||||
#expect(await waitForRunning(pm, id: "t17"))
|
let t16Running = await waitForRunning(pm, id: "t16")
|
||||||
#expect(await pm.killAll() == 2)
|
XCTAssertTrue(t16Running)
|
||||||
|
let t17Running = await waitForRunning(pm, id: "t17")
|
||||||
|
XCTAssertTrue(t17Running)
|
||||||
|
let killed = await pm.killAll()
|
||||||
|
XCTAssertEqual(killed, 2)
|
||||||
_ = try await capTask.value
|
_ = try await capTask.value
|
||||||
#expect(await waitForExit(in: streamBox))
|
let streamExit = await waitForExit(in: streamBox)
|
||||||
#expect(await waitForExit(in: capBox))
|
XCTAssertTrue(streamExit)
|
||||||
|
let capExit = await waitForExit(in: capBox)
|
||||||
|
XCTAssertTrue(capExit)
|
||||||
// Grace window outlasts the streaming finalize watchdog.
|
// Grace window outlasts the streaming finalize watchdog.
|
||||||
try await Task.sleep(for: .milliseconds(2500))
|
try await Task.sleep(nanoseconds: 2_500_000_000)
|
||||||
streamObserver.cancel()
|
streamObserver.cancel()
|
||||||
capObserver.cancel()
|
capObserver.cancel()
|
||||||
#expect(!(await pm.isRunning("t16")))
|
let t16RunningAfter = await pm.isRunning("t16")
|
||||||
#expect(!(await pm.isRunning("t17")))
|
XCTAssertFalse(t16RunningAfter)
|
||||||
#expect(exitCount(in: streamBox) == 1)
|
let t17RunningAfter = await pm.isRunning("t17")
|
||||||
#expect(exitCount(in: capBox) == 1)
|
XCTAssertFalse(t17RunningAfter)
|
||||||
|
XCTAssertEqual(exitCount(in: streamBox), 1)
|
||||||
|
XCTAssertEqual(exitCount(in: capBox), 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Direct contracts for the shared logged-run helper (issue #80).
|
/// 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`)
|
/// `runLogged` owns the running-flag transition (`false → true → false`)
|
||||||
/// and the log-reset decision; these tests pin both sides of the
|
/// and the log-reset decision; these tests pin both sides of the
|
||||||
/// contract plus the coalesced `@MainActor` log hop.
|
/// contract plus the coalesced `@MainActor` log hop.
|
||||||
@Suite("ProcessRunSupport runLogged")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ProcessRunSupportTests {
|
final class ProcessRunSupportTests: XCTestCase {
|
||||||
|
|
||||||
private struct SentinelError: Error {}
|
private struct SentinelError: Error {}
|
||||||
|
|
||||||
@Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved")
|
func testSuccessTransitions() async throws {
|
||||||
func successTransitions() async throws {
|
|
||||||
var running: [Bool] = []
|
var running: [Bool] = []
|
||||||
var resets = 0
|
var resets = 0
|
||||||
var received: [String] = []
|
var received: [String] = []
|
||||||
@@ -31,20 +29,19 @@ struct ProcessRunSupportTests {
|
|||||||
return 42
|
return 42
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(result == 42)
|
XCTAssertEqual(result, 42)
|
||||||
#expect(running == [true, false])
|
XCTAssertEqual(running, [true, false])
|
||||||
#expect(resets == 1)
|
XCTAssertEqual(resets, 1)
|
||||||
|
|
||||||
// The sink hops back through a main-actor Task; yield until the
|
// The sink hops back through a main-actor Task; yield until the
|
||||||
// coalesced batch lands.
|
// coalesced batch lands.
|
||||||
for _ in 0..<200 where received.isEmpty {
|
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 testFailureTransitions() async throws {
|
||||||
func failureTransitions() async throws {
|
|
||||||
var running: [Bool] = []
|
var running: [Bool] = []
|
||||||
var resets = 0
|
var resets = 0
|
||||||
|
|
||||||
@@ -56,12 +53,12 @@ struct ProcessRunSupportTests {
|
|||||||
) { _ -> Int in
|
) { _ -> Int in
|
||||||
throw SentinelError()
|
throw SentinelError()
|
||||||
}
|
}
|
||||||
Issue.record("Expected runLogged to rethrow")
|
XCTFail("Expected runLogged to rethrow")
|
||||||
} catch is SentinelError {
|
} catch is SentinelError {
|
||||||
// Expected path.
|
// Expected path.
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(running == [true, false])
|
XCTAssertEqual(running, [true, false])
|
||||||
#expect(resets == 1)
|
XCTAssertEqual(resets, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// A `FileManager` subclass that reports a temporary directory as the
|
/// A `FileManager` subclass that reports a temporary directory as the
|
||||||
@@ -18,8 +18,7 @@ private final class TestFileManager: FileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ProfileInstaller")
|
final class ProfileInstallerTests: XCTestCase {
|
||||||
struct ProfileInstallerTests {
|
|
||||||
|
|
||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
@@ -39,8 +38,7 @@ struct ProfileInstallerTests {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Installs .icc to user ColorSync folder")
|
func testUserInstall() throws {
|
||||||
func userInstall() throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let testFM = TestFileManager(home: tmp)
|
let testFM = TestFileManager(home: tmp)
|
||||||
@@ -51,15 +49,14 @@ struct ProfileInstallerTests {
|
|||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(result.registered)
|
XCTAssertTrue(result.registered)
|
||||||
#expect(!result.overwritten)
|
XCTAssertFalse(result.overwritten)
|
||||||
#expect(!result.renamed)
|
XCTAssertFalse(result.renamed)
|
||||||
#expect(result.destPath.hasSuffix("test.icc"))
|
XCTAssertTrue(result.destPath.hasSuffix("test.icc"))
|
||||||
#expect(fm.fileExists(atPath: result.destPath))
|
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Overwrite succeeds and replaces the existing file")
|
func testOverwriteSucceeds() throws {
|
||||||
func overwriteSucceeds() throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let testFM = TestFileManager(home: tmp)
|
let testFM = TestFileManager(home: tmp)
|
||||||
@@ -70,7 +67,7 @@ struct ProfileInstallerTests {
|
|||||||
config: InstallProfileConfig(sourceURL: source),
|
config: InstallProfileConfig(sourceURL: source),
|
||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
#expect(!first.overwritten)
|
XCTAssertFalse(first.overwritten)
|
||||||
|
|
||||||
// Change the source contents.
|
// Change the source contents.
|
||||||
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
|
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
|
||||||
@@ -87,15 +84,14 @@ struct ProfileInstallerTests {
|
|||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(second.overwritten)
|
XCTAssertTrue(second.overwritten)
|
||||||
#expect(!second.renamed)
|
XCTAssertFalse(second.renamed)
|
||||||
#expect(fm.fileExists(atPath: second.destPath))
|
XCTAssertTrue(fm.fileExists(atPath: second.destPath))
|
||||||
let installed = try Data(contentsOf: URL(fileURLWithPath: 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 testPreservesIcmExtension() throws {
|
||||||
func preservesIcmExtension() throws {
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let testFM = TestFileManager(home: tmp)
|
let testFM = TestFileManager(home: tmp)
|
||||||
let source = try makeSource(at: tmp, name: "m5_profile.icm")
|
let source = try makeSource(at: tmp, name: "m5_profile.icm")
|
||||||
@@ -105,12 +101,11 @@ struct ProfileInstallerTests {
|
|||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm")
|
XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm")
|
||||||
#expect(result.destPath.hasSuffix("m5_profile.icm"))
|
XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Rejects parent traversal in source path")
|
func testRejectsParentTraversal() throws {
|
||||||
func rejectsParentTraversal() throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
|
|
||||||
@@ -125,20 +120,19 @@ struct ProfileInstallerTests {
|
|||||||
let sourceURL = tmp
|
let sourceURL = tmp
|
||||||
.appendingPathComponent("..")
|
.appendingPathComponent("..")
|
||||||
.appendingPathComponent(naughtyName)
|
.appendingPathComponent(naughtyName)
|
||||||
#expect(fm.fileExists(atPath: sourceURL.path))
|
XCTAssertTrue(fm.fileExists(atPath: sourceURL.path))
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
|
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
|
||||||
Issue.record("Expected unsafeStem error")
|
XCTFail("Expected unsafeStem error")
|
||||||
} catch let error as ProfileInstallError {
|
} 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 {
|
} catch {
|
||||||
Issue.record("Unexpected error type: \(error)")
|
XCTFail("Unexpected error type: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Allows stems with consecutive dots like foo..bar")
|
func testAllowsDoubleDotStem() throws {
|
||||||
func allowsDoubleDotStem() throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let testFM = TestFileManager(home: tmp)
|
let testFM = TestFileManager(home: tmp)
|
||||||
@@ -149,23 +143,22 @@ struct ProfileInstallerTests {
|
|||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(result.destPath.hasSuffix("foo..bar.icc"))
|
XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc"))
|
||||||
#expect(fm.fileExists(atPath: result.destPath))
|
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Rejects source files that are too small")
|
func testRejectsSmallSource() throws {
|
||||||
func rejectsSmallSource() throws {
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let source = tmp.appendingPathComponent("tiny.icc")
|
let source = tmp.appendingPathComponent("tiny.icc")
|
||||||
try Data(repeating: 0, count: 64).write(to: source)
|
try Data(repeating: 0, count: 64).write(to: source)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
|
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
|
||||||
Issue.record("Expected sourceTooSmall error")
|
XCTFail("Expected sourceTooSmall error")
|
||||||
} catch let error as ProfileInstallError {
|
} 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 {
|
} catch {
|
||||||
Issue.record("Unexpected error type: \(error)")
|
XCTFail("Unexpected error type: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
private func tempStoreURL() -> URL {
|
private func tempStoreURL() -> URL {
|
||||||
@@ -8,92 +8,90 @@ private func tempStoreURL() -> URL {
|
|||||||
.appendingPathComponent("settings.json")
|
.appendingPathComponent("settings.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("AppSettings")
|
final class AppSettingsTests: XCTestCase {
|
||||||
struct AppSettingsTests {
|
func testDefaults() {
|
||||||
@Test func defaults() {
|
|
||||||
let s = AppSettings.default
|
let s = AppSettings.default
|
||||||
#expect(s.argyllBinaryDir == nil)
|
XCTAssertNil(s.argyllBinaryDir)
|
||||||
#expect(s.defaultInstrument == nil)
|
XCTAssertNil(s.defaultInstrument)
|
||||||
#expect(s.logLevel == nil)
|
XCTAssertNil(s.logLevel)
|
||||||
#expect(s.deltaEGoodMax == 2.0)
|
XCTAssertEqual(s.deltaEGoodMax, 2.0)
|
||||||
#expect(s.deltaEWarningMax == 5.0)
|
XCTAssertEqual(s.deltaEWarningMax, 5.0)
|
||||||
#expect(s.customPresets.isEmpty)
|
XCTAssertTrue(s.customPresets.isEmpty)
|
||||||
#expect(!s.enableI1Pro2Leds)
|
XCTAssertFalse(s.enableI1Pro2Leds)
|
||||||
#expect(s.calibrationStaleDays == 30)
|
XCTAssertEqual(s.calibrationStaleDays, 30)
|
||||||
#expect(s.defaultInstallLocation == .user)
|
XCTAssertEqual(s.defaultInstallLocation, .user)
|
||||||
#expect(s.askBeforeOverwriteProfile)
|
XCTAssertTrue(s.askBeforeOverwriteProfile)
|
||||||
#expect(!s.openColorPanelAfterInstall)
|
XCTAssertFalse(s.openColorPanelAfterInstall)
|
||||||
#expect(s.isValid)
|
XCTAssertTrue(s.isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func negativeThresholds() {
|
func testNegativeThresholds() {
|
||||||
var s = AppSettings.default
|
var s = AppSettings.default
|
||||||
s.deltaEGoodMax = -1
|
s.deltaEGoodMax = -1
|
||||||
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
|
XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE])
|
||||||
s.deltaEGoodMax = 2.0
|
s.deltaEGoodMax = 2.0
|
||||||
s.deltaEWarningMax = -0.5
|
s.deltaEWarningMax = -0.5
|
||||||
// -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too
|
// -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too
|
||||||
#expect(s.validate() == [
|
XCTAssertTrue(s.validate() == [
|
||||||
AppSettings.errorNegativeDeltaE,
|
AppSettings.errorNegativeDeltaE,
|
||||||
AppSettings.errorThresholdOrder,
|
AppSettings.errorThresholdOrder,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func goodMustBeStrictlyLessThanWarning() {
|
func testGoodMustBeStrictlyLessThanWarning() {
|
||||||
var s = AppSettings.default
|
var s = AppSettings.default
|
||||||
s.deltaEGoodMax = 5.0
|
s.deltaEGoodMax = 5.0
|
||||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
|
||||||
s.deltaEGoodMax = 6.0
|
s.deltaEGoodMax = 6.0
|
||||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
|
||||||
s.deltaEGoodMax = 4.9
|
s.deltaEGoodMax = 4.9
|
||||||
#expect(s.isValid)
|
XCTAssertTrue(s.isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func snakeCaseKeys() throws {
|
func testSnakeCaseKeys() throws {
|
||||||
let s = AppSettings.default
|
let s = AppSettings.default
|
||||||
let data = try JSONEncoder().encode(s)
|
let data = try JSONEncoder().encode(s)
|
||||||
let json = String(data: data, encoding: .utf8)!
|
let json = String(data: data, encoding: .utf8)!
|
||||||
#expect(json.contains("\"delta_e_good_max\""))
|
XCTAssertTrue(json.contains("\"delta_e_good_max\""))
|
||||||
#expect(json.contains("\"default_install_location\""))
|
XCTAssertTrue(json.contains("\"default_install_location\""))
|
||||||
#expect(json.contains("\"enable_i1pro2_leds\""))
|
XCTAssertTrue(json.contains("\"enable_i1pro2_leds\""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("SettingsStore")
|
final class SettingsStoreTests: XCTestCase {
|
||||||
struct SettingsStoreTests {
|
func testRoundTrip() throws {
|
||||||
@Test func roundTrip() throws {
|
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
var s = AppSettings.default
|
var s = AppSettings.default
|
||||||
s.deltaEGoodMax = 1.5
|
s.deltaEGoodMax = 1.5
|
||||||
s.defaultInstrument = "p3"
|
s.defaultInstrument = "p3"
|
||||||
try store.save(s)
|
try store.save(s)
|
||||||
#expect(store.load() == s)
|
XCTAssertEqual(store.load(), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func corruptJsonFallsBackToDefaults() throws {
|
func testCorruptJsonFallsBackToDefaults() throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
#expect(SettingsStore(fileURL: url).load() == .default)
|
XCTAssertEqual(SettingsStore(fileURL: url).load(), .default)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func missingFileReturnsDefaults() {
|
func testMissingFileReturnsDefaults() {
|
||||||
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
|
XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func invalidSettingsNotPersisted() throws {
|
func testInvalidSettingsNotPersisted() throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
var s = AppSettings.default
|
var s = AppSettings.default
|
||||||
s.deltaEGoodMax = 9.0 // >= warning 5.0
|
s.deltaEGoodMax = 9.0 // >= warning 5.0
|
||||||
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
|
XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
|
||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
|
func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
var valid = AppSettings.default
|
var valid = AppSettings.default
|
||||||
@@ -109,13 +107,13 @@ struct SettingsStoreTests {
|
|||||||
|
|
||||||
var invalid = AppSettings.default
|
var invalid = AppSettings.default
|
||||||
invalid.deltaEGoodMax = 9.0
|
invalid.deltaEGoodMax = 9.0
|
||||||
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
|
XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
|
||||||
#expect(try Data(contentsOf: url) == originalBytes)
|
XCTAssertEqual(try Data(contentsOf: url), originalBytes)
|
||||||
#expect(!fired)
|
XCTAssertFalse(fired)
|
||||||
#expect(store.load() == valid)
|
XCTAssertEqual(store.load(), valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func savePostsNotification() async throws {
|
func testSavePostsNotification() async throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
var fired = false
|
var fired = false
|
||||||
@@ -124,12 +122,11 @@ struct SettingsStoreTests {
|
|||||||
) { _ in fired = true }
|
) { _ in fired = true }
|
||||||
defer { NotificationCenter.default.removeObserver(token) }
|
defer { NotificationCenter.default.removeObserver(token) }
|
||||||
try store.save(.default)
|
try store.save(.default)
|
||||||
#expect(fired)
|
XCTAssertTrue(fired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("LogSink")
|
final class LogSinkTests: XCTestCase {
|
||||||
struct LogSinkTests {
|
|
||||||
private func tempLog() -> (URL, LogSink) {
|
private func tempLog() -> (URL, LogSink) {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
|
||||||
@@ -137,26 +134,26 @@ struct LogSinkTests {
|
|||||||
return (url, LogSink(fileURL: url))
|
return (url, LogSink(fileURL: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func writesFormattedLines() {
|
func testWritesFormattedLines() {
|
||||||
let (url, sink) = tempLog()
|
let (url, sink) = tempLog()
|
||||||
sink.setLevel(.debug)
|
sink.setLevel(.debug)
|
||||||
sink.write(level: .info, category: "test", message: "hello")
|
sink.write(level: .info, category: "test", message: "hello")
|
||||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||||
#expect(content.contains("[INFO] test: hello"))
|
XCTAssertTrue(content.contains("[INFO] test: hello"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func levelFilteringIsLive() {
|
func testLevelFilteringIsLive() {
|
||||||
let (url, sink) = tempLog()
|
let (url, sink) = tempLog()
|
||||||
sink.setLevel(.error)
|
sink.setLevel(.error)
|
||||||
sink.write(level: .info, category: "t", message: "hidden")
|
sink.write(level: .info, category: "t", message: "hidden")
|
||||||
sink.setLevel(.info) // runtime change, no restart (#158)
|
sink.setLevel(.info) // runtime change, no restart (#158)
|
||||||
sink.write(level: .info, category: "t", message: "shown")
|
sink.write(level: .info, category: "t", message: "shown")
|
||||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||||
#expect(!content.contains("hidden"))
|
XCTAssertFalse(content.contains("hidden"))
|
||||||
#expect(content.contains("shown"))
|
XCTAssertTrue(content.contains("shown"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func rotatesAt5MiBKeeping5Segments() throws {
|
func testRotatesAt5MiBKeeping5Segments() throws {
|
||||||
let (url, sink) = tempLog()
|
let (url, sink) = tempLog()
|
||||||
sink.setLevel(.trace)
|
sink.setLevel(.trace)
|
||||||
// Pre-fill the active log just under the cap, then cross it.
|
// Pre-fill the active log just under the cap, then cross it.
|
||||||
@@ -167,20 +164,20 @@ struct LogSinkTests {
|
|||||||
try big.write(to: url, atomically: true, encoding: .utf8)
|
try big.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
sink.write(level: .info, category: "t", message: "trigger rotation")
|
sink.write(level: .info, category: "t", message: "trigger rotation")
|
||||||
#expect(FileManager.default.fileExists(
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
atPath: url.appendingPathExtension("1").path
|
atPath: url.appendingPathExtension("1").path
|
||||||
))
|
))
|
||||||
// Active log is small again.
|
// Active log is small again.
|
||||||
let size = try FileManager.default.attributesOfItem(
|
let size = try FileManager.default.attributesOfItem(
|
||||||
atPath: url.path
|
atPath: url.path
|
||||||
)[.size] as? UInt64
|
)[.size] as? UInt64
|
||||||
#expect((size ?? 0) < 1024)
|
XCTAssertTrue((size ?? 0) < 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func tailExcerptCaps() throws {
|
func testTailExcerptCaps() throws {
|
||||||
let (url, sink) = tempLog()
|
let (url, sink) = tempLog()
|
||||||
sink.setLevel(.debug)
|
sink.setLevel(.debug)
|
||||||
sink.write(level: .info, category: "t", message: "line")
|
sink.write(level: .info, category: "t", message: "line")
|
||||||
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Testing
|
|
||||||
import XCTest
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@@ -220,11 +219,9 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArgyllRunner Targen")
|
final class ArgyllRunnerTargenTests: XCTestCase {
|
||||||
struct ArgyllRunnerTargenTests {
|
|
||||||
|
|
||||||
@Test("Successful targen execution creates .ti1 and returns URL")
|
func testSuccessfulTargenExecution() async throws {
|
||||||
func successfulTargenExecution() async throws {
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||||
@@ -270,14 +267,13 @@ struct ArgyllRunnerTargenTests {
|
|||||||
box.append(batch)
|
box.append(batch)
|
||||||
}
|
}
|
||||||
logLines = box.lines
|
logLines = box.lines
|
||||||
#expect(logLines.contains("Generating patches..."))
|
XCTAssertTrue(logLines.contains("Generating patches..."))
|
||||||
|
|
||||||
#expect(FileManager.default.fileExists(atPath: ti1URL.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path))
|
||||||
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failed targen execution throws toolFailed")
|
func testFailedTargenExecution() async throws {
|
||||||
func failedTargenExecution() async throws {
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||||
@@ -304,14 +300,15 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
|
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "targen", code: 1, logs: ["Error: something went wrong"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Targen exit 0 without .ti1 throws missingArtefact")
|
func testMissingArtefactThrows() async throws {
|
||||||
func missingArtefactThrows() async throws {
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||||
@@ -338,9 +335,11 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.missingArtefact(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tempDir.appendingPathComponent("no_file.ti1").path)) {
|
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .missingArtefact(
|
||||||
|
tempDir.appendingPathComponent("no_file.ti1").path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Dataset-import error contracts through the
|
/// Dataset-import error contracts through the
|
||||||
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
|
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
|
||||||
/// failures must surface identically as a single `.error` Notice.
|
/// failures must surface identically as a single `.error` Notice.
|
||||||
@Suite("TargetWorkflowViewModel dataset import")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct TargetWorkflowViewModelTests {
|
final class TargetWorkflowViewModelTests: XCTestCase {
|
||||||
|
|
||||||
@Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'")
|
func testMalformedDatasetNotice() throws {
|
||||||
func malformedDatasetNotice() throws {
|
|
||||||
let env = try TestAppEnvironment.make()
|
let env = try TestAppEnvironment.make()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
let vm = TargetWorkflowViewModel(environment: env.environment)
|
let vm = TargetWorkflowViewModel(environment: env.environment)
|
||||||
@@ -21,13 +19,12 @@ struct TargetWorkflowViewModelTests {
|
|||||||
|
|
||||||
vm.importMeasurementDataset(from: bad)
|
vm.importMeasurementDataset(from: bad)
|
||||||
|
|
||||||
let notice = try #require(vm.wizard.notice)
|
let notice = try XCTUnwrap(vm.wizard.notice)
|
||||||
#expect(notice.kind == .error)
|
XCTAssertEqual(notice.kind, .error)
|
||||||
#expect(notice.text.hasPrefix("Import failed:"))
|
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'")
|
func testMissingDatasetNotice() throws {
|
||||||
func missingDatasetNotice() throws {
|
|
||||||
let env = try TestAppEnvironment.make()
|
let env = try TestAppEnvironment.make()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
let vm = TargetWorkflowViewModel(environment: env.environment)
|
let vm = TargetWorkflowViewModel(environment: env.environment)
|
||||||
@@ -35,8 +32,8 @@ struct TargetWorkflowViewModelTests {
|
|||||||
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
|
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
|
||||||
vm.importMeasurementDataset(from: missing)
|
vm.importMeasurementDataset(from: missing)
|
||||||
|
|
||||||
let notice = try #require(vm.wizard.notice)
|
let notice = try XCTUnwrap(vm.wizard.notice)
|
||||||
#expect(notice.kind == .error)
|
XCTAssertEqual(notice.kind, .error)
|
||||||
#expect(notice.text.hasPrefix("Import failed:"))
|
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("VerificationHistoryStore")
|
final class VerificationHistoryStoreTests: XCTestCase {
|
||||||
struct VerificationHistoryStoreTests {
|
|
||||||
|
|
||||||
@Test("Append and cap")
|
func testAppendAndCap() async throws {
|
||||||
func appendAndCap() async throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -29,12 +27,11 @@ struct VerificationHistoryStoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let all = await store.all()
|
let all = await store.all()
|
||||||
#expect(all.count == 3)
|
XCTAssertEqual(all.count, 3)
|
||||||
#expect(all.first?.avgDE == 2.0)
|
XCTAssertEqual(all.first?.avgDE, 2.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Parse failure preserves file")
|
func testParseFailurePreservesFile() async {
|
||||||
func parseFailurePreservesFile() async {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -45,14 +42,13 @@ struct VerificationHistoryStoreTests {
|
|||||||
let store = VerificationHistoryStore(url: url)
|
let store = VerificationHistoryStore(url: url)
|
||||||
do {
|
do {
|
||||||
_ = try await store.load()
|
_ = try await store.load()
|
||||||
Issue.record("load() should throw on invalid JSON")
|
XCTFail("load() should throw on invalid JSON")
|
||||||
} catch {
|
} catch {
|
||||||
#expect(fm.fileExists(atPath: url.path))
|
XCTAssertTrue(fm.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Append loads existing records first")
|
func testAppendLoadsExisting() async throws {
|
||||||
func appendLoadsExisting() async throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -89,13 +85,12 @@ struct VerificationHistoryStoreTests {
|
|||||||
_ = try await store2.append(new)
|
_ = try await store2.append(new)
|
||||||
|
|
||||||
let all = await store2.all()
|
let all = await store2.all()
|
||||||
#expect(all.count == 2)
|
XCTAssertEqual(all.count, 2)
|
||||||
#expect(all.contains { $0.id == "vr-existing" })
|
XCTAssertTrue(all.contains { $0.id == "vr-existing" })
|
||||||
#expect(all.contains { $0.id == "vr-new" })
|
XCTAssertTrue(all.contains { $0.id == "vr-new" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Append does not overwrite an unparseable file")
|
func testAppendPreservesUnparseableFile() async {
|
||||||
func appendPreservesUnparseableFile() async {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -119,20 +114,19 @@ struct VerificationHistoryStoreTests {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await store.append(record)
|
_ = try await store.append(record)
|
||||||
Issue.record("append() should propagate the load error")
|
XCTFail("append() should propagate the load error")
|
||||||
} catch {
|
} catch {
|
||||||
#expect(fm.fileExists(atPath: url.path))
|
XCTAssertTrue(fm.fileExists(atPath: url.path))
|
||||||
if let data = try? Data(contentsOf: url),
|
if let data = try? Data(contentsOf: url),
|
||||||
let contents = String(data: data, encoding: .utf8) {
|
let contents = String(data: data, encoding: .utf8) {
|
||||||
#expect(contents == badJSON)
|
XCTAssertEqual(contents, badJSON)
|
||||||
} else {
|
} else {
|
||||||
Issue.record("Could not read preserved file")
|
XCTFail("Could not read preserved file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Clear does not overwrite an unparseable file")
|
func testClearPreservesUnparseableFile() async {
|
||||||
func clearPreservesUnparseableFile() async {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -144,15 +138,14 @@ struct VerificationHistoryStoreTests {
|
|||||||
let store = VerificationHistoryStore(url: url)
|
let store = VerificationHistoryStore(url: url)
|
||||||
do {
|
do {
|
||||||
try await store.clear()
|
try await store.clear()
|
||||||
Issue.record("clear() should propagate the load error")
|
XCTFail("clear() should propagate the load error")
|
||||||
} catch {
|
} catch {
|
||||||
let contents = try? String(contentsOf: url, encoding: .utf8)
|
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 testIso8601RoundTrip() async throws {
|
||||||
func iso8601RoundTrip() async throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -174,16 +167,15 @@ struct VerificationHistoryStoreTests {
|
|||||||
_ = try await store1.append(record)
|
_ = try await store1.append(record)
|
||||||
|
|
||||||
let text = try String(contentsOf: url, encoding: .utf8)
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(text.contains(ISO8601DateFormatter().string(from: timestamp)))
|
XCTAssertTrue(text.contains(ISO8601DateFormatter().string(from: timestamp)))
|
||||||
|
|
||||||
let store2 = VerificationHistoryStore(url: url)
|
let store2 = VerificationHistoryStore(url: url)
|
||||||
let loaded = try await store2.load()
|
let loaded = try await store2.load()
|
||||||
#expect(loaded.count == 1)
|
XCTAssertEqual(loaded.count, 1)
|
||||||
#expect(loaded.first?.timestamp == timestamp)
|
XCTAssertEqual(loaded.first?.timestamp, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("CSV export quoting")
|
func testCsvQuoting() async throws {
|
||||||
func csvQuoting() async throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -204,7 +196,7 @@ struct VerificationHistoryStoreTests {
|
|||||||
_ = try await store.append(record)
|
_ = try await store.append(record)
|
||||||
|
|
||||||
let csv = await store.exportCSV()
|
let csv = await store.exportCSV()
|
||||||
#expect(csv.contains("\"a,b\""))
|
XCTAssertTrue(csv.contains("\"a,b\""))
|
||||||
#expect(csv.contains("\"\"quoted\"\""))
|
XCTAssertTrue(csv.contains("\"\"quoted\"\""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Issue #29 — `CAL_` basename must be restored on relaunch and on any
|
/// Issue #29 — `CAL_` basename must be restored on relaunch and on any
|
||||||
/// attempt to navigate to a non-calibration stage that would use it.
|
/// attempt to navigate to a non-calibration stage that would use it.
|
||||||
@Suite("WizardCalibrationSession")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct WizardCalibrationSessionTests {
|
final class WizardCalibrationSessionTests: XCTestCase {
|
||||||
|
|
||||||
private func tempURL() -> URL {
|
private func tempURL() -> URL {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager.default.temporaryDirectory
|
||||||
@@ -24,8 +23,7 @@ struct WizardCalibrationSessionTests {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Persist and restore calibrationOriginalBasename across a relaunch")
|
func testRelaunchRestoresOriginal() throws {
|
||||||
func relaunchRestoresOriginal() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = WizardStateStore(fileURL: url)
|
let store = WizardStateStore(fileURL: url)
|
||||||
var saved = WizardState(
|
var saved = WizardState(
|
||||||
@@ -39,14 +37,13 @@ struct WizardCalibrationSessionTests {
|
|||||||
|
|
||||||
let model = WizardViewModel(stateStore: store)
|
let model = WizardViewModel(stateStore: store)
|
||||||
|
|
||||||
#expect(model.basename == "DemoTarget")
|
XCTAssertEqual(model.basename, "DemoTarget")
|
||||||
#expect(model.calibrationOriginalBasename == "")
|
XCTAssertEqual(model.calibrationOriginalBasename, "")
|
||||||
#expect(model.sessionMode == .profile)
|
XCTAssertEqual(model.sessionMode, .profile)
|
||||||
#expect(model.stage == .generate)
|
XCTAssertEqual(model.stage, .generate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original")
|
func testGoToBuildProfileRefusesAndRestores() throws {
|
||||||
func goToBuildProfileRefusesAndRestores() throws {
|
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = WizardStateStore(fileURL: url)
|
let store = WizardStateStore(fileURL: url)
|
||||||
@@ -60,14 +57,13 @@ struct WizardCalibrationSessionTests {
|
|||||||
|
|
||||||
model.go(to: .buildProfile)
|
model.go(to: .buildProfile)
|
||||||
|
|
||||||
#expect(model.basename == "DemoTarget")
|
XCTAssertEqual(model.basename, "DemoTarget")
|
||||||
#expect(model.calibrationOriginalBasename == "")
|
XCTAssertEqual(model.calibrationOriginalBasename, "")
|
||||||
#expect(model.sessionMode == .profile)
|
XCTAssertEqual(model.sessionMode, .profile)
|
||||||
#expect(model.stage == .calibrate)
|
XCTAssertEqual(model.stage, .calibrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration")
|
func testGoToLayoutStaysCal() throws {
|
||||||
func goToLayoutStaysCal() throws {
|
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = WizardStateStore(fileURL: url)
|
let store = WizardStateStore(fileURL: url)
|
||||||
@@ -81,9 +77,9 @@ struct WizardCalibrationSessionTests {
|
|||||||
|
|
||||||
model.go(to: .layOutPrint)
|
model.go(to: .layOutPrint)
|
||||||
|
|
||||||
#expect(model.basename == "CAL_DemoTarget")
|
XCTAssertEqual(model.basename, "CAL_DemoTarget")
|
||||||
#expect(model.calibrationOriginalBasename == "DemoTarget")
|
XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget")
|
||||||
#expect(model.sessionMode == .calibration)
|
XCTAssertEqual(model.sessionMode, .calibration)
|
||||||
#expect(model.stage == .layOutPrint)
|
XCTAssertEqual(model.stage, .layOutPrint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
private func artefacts(
|
private func artefacts(
|
||||||
@@ -16,77 +16,75 @@ private func artefacts(
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("WizardGating matrix")
|
final class WizardGatingTests: XCTestCase {
|
||||||
struct WizardGatingTests {
|
|
||||||
|
|
||||||
@Test func emptyProjectOnlyStage1() {
|
func testEmptyProjectOnlyStage1() {
|
||||||
let a = artefacts()
|
let a = artefacts()
|
||||||
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
|
XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a))
|
||||||
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
||||||
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
|
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)
|
let a = artefacts(ti1: true)
|
||||||
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
||||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
|
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a))
|
||||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stage3NeedsTi1AndTi2() {
|
func testStage3NeedsTi1AndTi2() {
|
||||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
||||||
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, 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.
|
// #109/#110: .ti2 alone must never unlock Stage 4.
|
||||||
let a = artefacts(ti1: true, ti2: true)
|
let a = artefacts(ti1: true, ti2: true)
|
||||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||||
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stage5NeedsTi3AndProfile() {
|
func testStage5NeedsTi3AndProfile() {
|
||||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
||||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
||||||
#expect(WizardGating.isUnlocked(
|
XCTAssertTrue(WizardGating.isUnlocked(
|
||||||
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
|
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func forwardGatedBackwardFree() {
|
func testForwardGatedBackwardFree() {
|
||||||
let a = artefacts()
|
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.
|
// 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.
|
// 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.
|
// 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() {
|
func testDeepestUnlocked() {
|
||||||
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
|
XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate)
|
||||||
#expect(WizardGating.deepestUnlocked(
|
XCTAssertEqual(WizardGating.deepestUnlocked(
|
||||||
artefacts: artefacts(ti1: true, ti2: true)
|
artefacts: artefacts(ti1: true, ti2: true)
|
||||||
) == .measure)
|
), .measure)
|
||||||
#expect(WizardGating.deepestUnlocked(
|
XCTAssertEqual(WizardGating.deepestUnlocked(
|
||||||
artefacts: artefacts(ti3: true, profile: true)
|
artefacts: artefacts(ti3: true, profile: true)
|
||||||
) == .verifyInstall)
|
), .verifyInstall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("WizardStateStore")
|
final class WizardStateStoreTests: XCTestCase {
|
||||||
struct WizardStateStoreTests {
|
|
||||||
private func tempURL() -> URL {
|
private func tempURL() -> URL {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("wizard_state.json")
|
.appendingPathComponent("wizard_state.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func roundTrip() throws {
|
func testRoundTrip() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = WizardStateStore(fileURL: url)
|
let store = WizardStateStore(fileURL: url)
|
||||||
var s = WizardState()
|
var s = WizardState()
|
||||||
@@ -97,43 +95,43 @@ struct WizardStateStoreTests {
|
|||||||
s.profileBasename = "imported"
|
s.profileBasename = "imported"
|
||||||
s.calibrationOriginalBasename = "pre-cal"
|
s.calibrationOriginalBasename = "pre-cal"
|
||||||
try store.save(s)
|
try store.save(s)
|
||||||
#expect(store.load() == s)
|
XCTAssertEqual(store.load(), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func missingFileDefaults() {
|
func testMissingFileDefaults() {
|
||||||
let s = WizardStateStore(fileURL: tempURL()).load()
|
let s = WizardStateStore(fileURL: tempURL()).load()
|
||||||
#expect(s == .default)
|
XCTAssertEqual(s, .default)
|
||||||
#expect(s.stage == .generate)
|
XCTAssertEqual(s.stage, .generate)
|
||||||
#expect(s.sessionMode == .profile)
|
XCTAssertEqual(s.sessionMode, .profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func corruptStageFallsBackToGenerate() throws {
|
func testCorruptStageFallsBackToGenerate() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
|
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
|
||||||
.write(to: url, atomically: true, encoding: .utf8)
|
.write(to: url, atomically: true, encoding: .utf8)
|
||||||
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
XCTAssertEqual(WizardStateStore(fileURL: url).load().stage, .generate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func corruptJsonReturnsDefaultAndKeepsBytes() throws {
|
func testCorruptJsonReturnsDefaultAndKeepsBytes() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
#expect(WizardStateStore(fileURL: url).load() == .default)
|
XCTAssertEqual(WizardStateStore(fileURL: url).load(), .default)
|
||||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
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)
|
var s = WizardState(sessionMode: .calibration)
|
||||||
let data = try JSONEncoder().encode(s)
|
let data = try JSONEncoder().encode(s)
|
||||||
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
|
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
|
||||||
#expect(decoded.sessionMode == .calibration)
|
XCTAssertEqual(decoded.sessionMode, .calibration)
|
||||||
s.sessionMode = .profile
|
s.sessionMode = .profile
|
||||||
#expect(s.sessionMode == .profile)
|
XCTAssertEqual(s.sessionMode, .profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user