diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift index 2f50ea7..204fdc0 100644 --- a/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift +++ b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift @@ -5,13 +5,13 @@ import Testing @Suite("ArgyllRunner Calibration") struct ArgyllRunnerCalibrationTests { - private func makeRunner() -> ArgyllRunner { + private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner { let binDir = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .appendingPathComponent("ICCeryUITests/Fixtures/bin") return ArgyllRunner( - processManager: .shared, + processManager: processManager, binaryResolver: BinaryResolver(overrideDir: binDir) ) } @@ -44,8 +44,9 @@ struct ArgyllRunnerCalibrationTests { @Test("Calibration targen from foo runs as process id targen_CAL_foo") func calibrationTargenProcessId() async throws { let testRoot = try makeTestDir() - let runner = makeRunner() - let events = ProcessManager.shared.events() + let pm = ProcessManager() + let runner = makeRunner(processManager: pm) + let events = pm.events() // Subscribed before spawn; the exit event is emitted before // runCalibrationTargen returns, so this always terminates. let sawExit = Task { @@ -87,10 +88,28 @@ struct ArgyllRunnerCalibrationTests { try? FileManager.default.removeItem(at: testRoot) } - @Test("printcal failure throws printcalFailed") + @Test("printcal failure throws toolFailed") func printcalFailureThrows() async throws { let testRoot = try makeTestDir() - let runner = makeRunner() + defer { try? FileManager.default.removeItem(at: testRoot) } + + // Per-test mock printcal that always fails — no global + // environment mutation, no shared fixture changes. + let binDir = try makeTestDir() + defer { try? FileManager.default.removeItem(at: binDir) } + let mockURL = binDir.appendingPathComponent("printcal") + try """ + #!/bin/sh + echo "printcal mock failure" >&2 + exit 1 + """.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir) + ) let output = testRoot.appendingPathComponent("CAL_demo.cal") let config = PrintcalConfig( ti3Basename: "CAL_demo", @@ -98,12 +117,9 @@ struct ArgyllRunnerCalibrationTests { outputURL: output ) - setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1) - defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") } - - await #expect(throws: (any Error).self) { + await #expect(throws: ArgyllRunnerError.toolFailed( + tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) { _ = try await runner.runPrintcal(config: config) } - try? FileManager.default.removeItem(at: testRoot) } } diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift index ce92e47..6cf0d4e 100644 --- a/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift +++ b/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift @@ -33,7 +33,7 @@ struct ArgyllRunnerColprofTests { try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) let runner = ArgyllRunner( - processManager: .shared, + processManager: ProcessManager(), binaryResolver: BinaryResolver(overrideDir: binDir) ) @@ -49,4 +49,32 @@ struct ArgyllRunnerColprofTests { try? FileManager.default.removeItem(at: testRoot) } + + @Test("Failing colprof throws toolFailed with code and logs") + func colprofFailureThrowsToolFailed() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("colprof-fail-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let mockURL = dir.appendingPathComponent("colprof") + try """ + #!/bin/sh + echo "colprof broke" >&2 + exit 4 + """.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir) + ) + let config = ColprofConfig(basename: "failrun", workingDirectory: dir) + + await #expect(throws: ArgyllRunnerError.toolFailed( + tool: "colprof", code: 4, logs: ["colprof broke"])) { + try await runner.runColprof(config: config) + } + } } diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift new file mode 100644 index 0000000..c1d114b --- /dev/null +++ b/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import ICCeryCore + +/// Focused contracts for the shared `runStreamingTool` loop (#79). +/// +/// Every test uses a per-test temporary directory, unique basenames, +/// and a fresh `ProcessManager` — no shared UI fixture scripts and no +/// process-environment mutation. +@Suite("ArgyllRunner streaming loop contracts") +struct ArgyllRunnerStreamingLoopTests { + + private func makeTempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("runner-loop-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func writeMock(_ name: String, _ body: String, in dir: URL) throws { + let url = dir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func makeRunner(binDir: URL) -> ArgyllRunner { + ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)) + } + + @Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines") + func nonZeroExitThrowsToolFailed() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try writeMock("targen", """ + #!/bin/sh + echo "Generating patches..." + echo "targen: too few patches" >&2 + exit 3 + """, in: dir) + let runner = makeRunner(binDir: dir) + let config = TargenConfig( + colourSpace: .rgb, patchCount: 800, whitePatches: 4, + blackPatches: 4, basename: "fail", workingDirectory: dir) + + do { + _ = try await runner.runTargen(config: config) + Issue.record("Expected toolFailed") + } catch let error as ArgyllRunnerError { + guard case .toolFailed(let tool, let code, let logs) = error else { + Issue.record("Expected toolFailed, got \(error)") + return + } + #expect(tool == "targen") + #expect(code == 3) + #expect(logs.contains("Generating patches...")) + #expect(logs.contains("targen: too few patches")) + } + } + + @Test("Exit 0 without expected artefact throws missingArtefact with the artefact path") + func zeroExitMissingArtefact() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try writeMock("targen", """ + #!/bin/sh + echo "done but wrote nothing" + exit 0 + """, in: dir) + let runner = makeRunner(binDir: dir) + let expectedPath = dir.appendingPathComponent("gone.ti1").path + let config = TargenConfig( + colourSpace: .rgb, patchCount: 800, whitePatches: 4, + blackPatches: 4, basename: "gone", workingDirectory: dir) + + await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) { + try await runner.runTargen(config: config) + } + } + + @Test("Immediate exit after one stdout line still delivers the line and succeeds") + func immediateExitDeliversLine() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try writeMock("targen", """ + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + echo "only line" + touch "$last.ti1" + exit 0 + """, in: dir) + let runner = makeRunner(binDir: dir) + let config = TargenConfig( + colourSpace: .rgb, patchCount: 800, whitePatches: 4, + blackPatches: 4, basename: "quick", workingDirectory: dir) + + let holder = LogHolder() + let url = try await runner.runTargen(config: config) { batch in + holder.append(batch) + } + #expect(url.lastPathComponent == "quick.ti1") + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(holder.lines.contains("only line")) + } + + @Test("colprof unterminated progress fragment reaches onLogBatch before exit") + func colprofPartialLineFlush() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + // The fragment is printed without a newline, then the mock sleeps + // past the 500 ms partial-line flush interval before writing the + // artefact and exiting — so the tail is delivered mid-run. + try writeMock("colprof", """ + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + printf 'Doing gamut mapping' + sleep 2 + touch "$last.icc" + exit 0 + """, in: dir) + let runner = makeRunner(binDir: dir) + let config = ColprofConfig(basename: "frag", workingDirectory: dir) + + let holder = LogHolder() + let url = try await runner.runColprof(config: config) { batch in + holder.append(batch) + } + #expect(url.lastPathComponent == "frag.icc") + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(holder.lines.contains("Doing gamut mapping")) + } + + @Test("toolFailed maps each tool to its user-facing description", + arguments: [ + (tool: "chartread", expected: "Chartread failed: boom"), + (tool: "average", expected: "Averaging failed: boom"), + (tool: "colprof", expected: "Profile creation failed: boom"), + (tool: "printcal", expected: "Calibration curve computation failed: boom"), + (tool: "applycal", expected: "Apply calibration failed: boom"), + (tool: "iccgamut", expected: "Gamut extraction failed: boom"), + (tool: "profcheck", expected: "Profile verification failed: boom"), + ]) + func toolDescriptions(tool: String, expected: String) { + let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"]) + #expect(error.errorDescription == expected) + } + + @Test("toolFailed falls back to a generic description for unmapped tools and empty logs") + func genericFallbacks() { + let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"]) + #expect(unknown.errorDescription == "Process exited with code 7") + + let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: []) + #expect(emptyLogs.errorDescription + == "Profile creation failed: exited with code 2") + } +} diff --git a/Tests/ICCeryCoreTests/PrinttargTests.swift b/Tests/ICCeryCoreTests/PrinttargTests.swift index 99eec2a..bab9fdc 100644 --- a/Tests/ICCeryCoreTests/PrinttargTests.swift +++ b/Tests/ICCeryCoreTests/PrinttargTests.swift @@ -366,7 +366,7 @@ struct ArgyllRunnerPrinttargTests { } } - @Test("Non-zero exit throws processFailed and stays on stage") + @Test("Non-zero exit throws toolFailed and stays on stage") func failure() async throws { let dir = try makeFixture(""" #!/bin/sh @@ -377,7 +377,8 @@ struct ArgyllRunnerPrinttargTests { let runner = ArgyllRunner( processManager: ProcessManager(), binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) - await #expect(throws: ArgyllRunnerError.self) { + await #expect(throws: ArgyllRunnerError.toolFailed( + tool: "printtarg", code: 3, logs: ["oops"])) { try await runner.runPrinttarg( config: PrinttargConfig(basename: "x", workingDirectory: dir)) } diff --git a/Tests/ICCeryCoreTests/TargenTests.swift b/Tests/ICCeryCoreTests/TargenTests.swift index 9929021..821efce 100644 --- a/Tests/ICCeryCoreTests/TargenTests.swift +++ b/Tests/ICCeryCoreTests/TargenTests.swift @@ -290,7 +290,7 @@ struct ArgyllRunnerTargenTests { #expect(ti1URL.lastPathComponent == "mock_test.ti1") } - @Test("Failed targen execution throws processFailed") + @Test("Failed targen execution throws toolFailed") func failedTargenExecution() async throws { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -318,7 +318,8 @@ struct ArgyllRunnerTargenTests { workingDirectory: tempDir ) - await #expect(throws: ArgyllRunnerError.self) { + await #expect(throws: ArgyllRunnerError.toolFailed( + tool: "targen", code: 1, logs: ["Error: something went wrong"])) { try await runner.runTargen(config: config) } } @@ -351,7 +352,8 @@ struct ArgyllRunnerTargenTests { workingDirectory: tempDir ) - await #expect(throws: ArgyllRunnerError.self) { + await #expect(throws: ArgyllRunnerError.missingArtefact( + tempDir.appendingPathComponent("no_file.ti1").path)) { try await runner.runTargen(config: config) } }