From 153b6194a33bb2389ddae14fd541e44af4db8f42 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 17:18:22 +0100 Subject: [PATCH] Implement #30: CGATS parser, canonical .ti3 writer, and dataset import. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CGATSParser, CGATSWriter, and CGATSSummary under ICCeryCore/CGATS. - Parse CTI3, CGATS.17, ISO28178, and CSV; synthesize COLOR_REP/DEVICE_CLASS. - Scale RGB/CMYK device coordinates from 0-255 to 0-100 when needed. - Round-trip parse, write, reparse with tab-delimited canonical .ti3. - Wire `Import Dataset…` in Stage 1 to open-panel import, write .ti3, and jump to Stage 4/5 while keeping Stages 1-2 locked without .ti1/.ti2. - Add CGATSParserTests and Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/CGATS/CGATSParser.swift | 372 ++++++++++++++++++ .../ICCeryCore/CGATS/CGATSSummary.swift | 20 + .../ICCeryCore/CGATS/CGATSWriter.swift | 82 ++++ Sources/ICCery/AppEnvironment.swift | 2 + Sources/ICCery/Stage1View.swift | 3 +- Sources/ICCery/TargetWorkflowViewModel.swift | 37 ++ Tests/ICCeryCoreTests/CGATSParserTests.swift | 134 +++++++ .../Milestone6CGATSUITests.swift | 64 +++ 8 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift create mode 100644 Tests/ICCeryCoreTests/CGATSParserTests.swift create mode 100644 Tests/ICCeryUITests/Milestone6CGATSUITests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift new file mode 100644 index 0000000..fec3f09 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift @@ -0,0 +1,372 @@ +import Foundation + +/// Errors that can occur while parsing CGATS-like data. +public enum CGATSParseError: Error, Equatable { + case emptyFile + case missingBeginDataFormat + case missingEndDataFormat + case missingBeginData + case missingEndData + case missingNumberOfFields + case missingNumberOfSets + case unknownFieldName(String) + case malformedRow(line: Int, reason: String) + case nonNumericValue(field: String, value: String, line: Int) + case outOfBoundsValue(field: String, value: Double, line: Int) + case implausibleValue(field: String, value: Double, line: Int) + case incorrectArity(line: Int, expected: Int, got: Int) +} + +/// One row of a CGATS dataset, keyed by canonical field name. +public struct CGATSSample: Sendable, Equatable { + public var id: String + public var loc: String? + public var values: [String: String] + + public init(id: String, loc: String? = nil, values: [String: String] = [:]) { + self.id = id + self.loc = loc + self.values = values + } +} + +/// A parsed CGATS / CTI3 / CSV dataset. +public struct CGATSDataset: Sendable, Equatable { + public var format: CGATSFormat + public var keywords: [String: String] + public var fieldNames: [String] + public var samples: [CGATSSample] + public var colorRep: String? + public var deviceClass: String? + public var targetInstrument: String? + + public init( + format: CGATSFormat, + keywords: [String: String] = [:], + fieldNames: [String] = [], + samples: [CGATSSample] = [], + colorRep: String? = nil, + deviceClass: String? = nil, + targetInstrument: String? = nil + ) { + self.format = format + self.keywords = keywords + self.fieldNames = fieldNames + self.samples = samples + self.colorRep = colorRep + self.deviceClass = deviceClass + self.targetInstrument = targetInstrument + } +} + +public enum CGATSFormat: String, Sendable, Equatable { + case cti3 = "CTI3" + case cgats17 = "CGATS.17" + case csv = "CSV" +} + +/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets. +public enum CGATSParser { + + /// Parse the contents of a CGATS-like file. + public static func parse( + _ contents: String, + sourceURL: URL? = nil + ) throws(CGATSParseError) -> CGATSDataset { + guard !contents.isEmpty else { throw .emptyFile } + + let ext = sourceURL?.pathExtension.lowercased() ?? "" + let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines) + .hasPrefix("SAMPLE_ID,") + + let (format, lines) = try preprocess(contents, isCSV: isCSV) + + var formatStart: Int? + var formatEnd: Int? + var dataStart: Int? + var dataEnd: Int? + var keywords = [String: String]() + + for (index, line) in lines.enumerated() { + switch Self.normalizedKeyword(line) { + case "BEGIN_DATA_FORMAT": formatStart = index + case "END_DATA_FORMAT": formatEnd = index + case "BEGIN_DATA": dataStart = index + case "END_DATA": dataEnd = index + default: + if let (key, value) = parseKeyword(line) { + keywords[key] = value + } + } + } + + guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else { + throw .missingBeginDataFormat + } + guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else { + throw .missingBeginData + } + + let rawFieldNames = splitFields(lines[formatStart + 1]) + let fieldNames = rawFieldNames.map { canonicalFieldName($0) } + + if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init), + numberOfFields != fieldNames.count { + // Warn only; the data format line is the source of truth. + } else if keywords["NUMBER_OF_FIELDS"] == nil { + // Optional header; do not fail. + } + + if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init), + numberOfSets != dataEnd - dataStart - 1 { + // Warn only; the actual rows are the source of truth. + } else if keywords["NUMBER_OF_SETS"] == nil { + // Optional header; do not fail. + } + + struct RawSample { + var id: String + var loc: String? + var numbers: [String: Double] = [:] + var strings: [String: String] = [:] + var lineIndex: Int + } + + var rawSamples = [RawSample]() + var groupMax: [String: Double] = [:] + + for offset in 1...(dataEnd - dataStart - 1) { + let lineIndex = dataStart + offset + let rawRow = splitFields(lines[lineIndex]) + guard rawRow.count == fieldNames.count else { + throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count) + } + + var sample = RawSample(id: String(offset), lineIndex: lineIndex) + for (i, name) in fieldNames.enumerated() { + let raw = stripInlineComment(rawRow[i]) + if isNumericField(name) { + let cleaned = raw.trimmingCharacters(in: .whitespaces) + if let number = parseNumber(cleaned) { + sample.numbers[name] = number + if let group = deviceGroup(name) { + groupMax[group, default: 0] = max(groupMax[group, default: 0], number) + } + } else if !cleaned.isEmpty { + throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1) + } + } else { + sample.strings[name] = raw + } + } + + sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset) + sample.loc = sample.strings["SAMPLE_LOC"] + rawSamples.append(sample) + } + + var samples = [CGATSSample]() + for raw in rawSamples { + var values = raw.strings + for (name, number) in raw.numbers { + var scaled = number + if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 { + scaled = number / 2.55 + } + values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1) + } + + var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values) + // Keep lookups by canonical keys, but also preserve original aliases. + let rawRow = splitFields(lines[raw.lineIndex]) + for (i, rawName) in rawFieldNames.enumerated() { + let canonical = canonicalFieldName(rawName) + if canonical != rawName { + sample.values[rawName] = rawRow[i] + } + } + samples.append(sample) + } + + let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames) + let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames) + + return CGATSDataset( + format: format, + keywords: keywords, + fieldNames: fieldNames, + samples: samples, + colorRep: colorRep, + deviceClass: deviceClass, + targetInstrument: keywords["TARGET_INSTRUMENT"] + ) + } + + /// Parse from a URL (throws as `Error` for public callers). + public static func parse(url: URL) throws -> CGATSDataset { + let contents = try String(contentsOf: url) + return try parse(contents, sourceURL: url) + } + + // MARK: - Internals + + private static func preprocess( + _ contents: String, + isCSV: Bool + ) throws(CGATSParseError) -> (CGATSFormat, [String]) { + let allLines = contents.components(separatedBy: .newlines) + var lines = [String]() + + var format: CGATSFormat? + for var line in allLines { + line = stripComment(line) + line = line.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty else { continue } + + if format == nil { + if line.hasPrefix("CTI3") { format = .cti3 } + else if line.hasPrefix("CGATS.17") { format = .cgats17 } + else if isCSV { format = .csv } + } + + if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" || + line == "BEGIN_DATA" || line == "END_DATA" || + (line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") || + line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) { + // These are exact keywords; keep them intact. + } + + lines.append(line) + } + + guard !lines.isEmpty else { throw .emptyFile } + + // Wrap a bare CSV / ISO28178 file in the canonical CGATS block + // structure so the boundary-based parser below can handle it. + if let format, format == .csv, + !lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) { + let header = lines[0] + let data = lines.dropFirst() + lines = [ + "CTI3", + "BEGIN_DATA_FORMAT", + header, + "END_DATA_FORMAT", + "BEGIN_DATA" + ] + Array(data) + [ + "END_DATA" + ] + return (.csv, lines) + } + + return (format ?? .cti3, lines) + } + + private static func stripComment(_ line: String) -> String { + if let range = line.range(of: "#") { + return String(line[.. String { + if let range = token.range(of: "#") { + return String(token[.. [String] { + // CTI3/CGATS.17 use whitespace/tabs; CSV uses commas. + if line.contains(",") { + return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } + } + return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + } + + private static func parseKeyword(_ line: String) -> (key: String, value: String)? { + // KEYWORD value or KEYWORD "value" + let tokens = splitFields(line) + guard let key = tokens.first else { return nil } + + // Data-boundary keywords are not value keywords. + let boundaryKeys = Set([ + "BEGIN_DATA_FORMAT", "END_DATA_FORMAT", + "BEGIN_DATA", "END_DATA" + ]) + guard !boundaryKeys.contains(key) else { return nil } + + let rawValue = tokens.dropFirst().joined(separator: " ") + let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + return (key, value) + } + + private static func normalizedKeyword(_ line: String) -> String { + line.uppercased().trimmingCharacters(in: .whitespaces) + } + + // MARK: - Field name normalization + + private static func canonicalFieldName(_ raw: String) -> String { + let upper = raw.uppercased() + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "-", with: "_") + switch upper { + case "SAMPLE_ID", "ID": return "SAMPLE_ID" + case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC" + case "SAMPLE_NAME": return "SAMPLE_ID" + case "LAB_L", "L*", "L_AB": return "LAB_L" + case "LAB_A", "A*", "A_AB": return "LAB_A" + case "LAB_B", "B*", "B_AB": return "LAB_B" + case "XYZ_X", "X": return "XYZ_X" + case "XYZ_Y", "Y": return "XYZ_Y" + case "XYZ_Z", "Z": return "XYZ_Z" + default: return upper + } + } + + private static func isNumericField(_ name: String) -> Bool { + let numericNames: Set = [ + "SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME" + ] + return !numericNames.contains(name) + } + + private static func parseNumber(_ raw: String) -> Double? { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.number(from: raw)?.doubleValue + } + + private static func validateValue(_ value: Double, field: String, line: Int) -> String { + var number = value + + // Plausibility checks for Lab and XYZ. + if field == "LAB_L" { number = max(0, min(160, number)) } + if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) } + if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) } + + return String(format: "%.4f", number) + } + + private static func deviceGroup(_ name: String) -> String? { + if name.hasPrefix("RGB_") { return "RGB" } + if name.hasPrefix("CMYK_") { return "CMYK" } + if name.hasPrefix("DEVICE_") { return "DEVICE" } + return nil + } + + private static func inferColorRep(fieldNames: [String]) -> String? { + if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" } + if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" } + if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" } + if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" } + return nil + } + + private static func inferDeviceClass(fieldNames: [String]) -> String? { + if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" } + if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" } + return "OUTPUT" + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift new file mode 100644 index 0000000..8c602a0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Human-readable summary of an imported CGATS dataset. +public struct CGATSSummary: Sendable, Equatable { + public let patchCount: Int + public let colorSpace: String? + public let deviceClass: String? + public let hasSpectral: Bool + public let previewRows: [String] + + public init(dataset: CGATSDataset, previewRowCount: Int = 4) { + self.patchCount = dataset.samples.count + self.colorSpace = dataset.colorRep + self.deviceClass = dataset.deviceClass + self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") } + self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in + "\(sample.id)" + (sample.loc.map { " \($0)" } ?? "") + }) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift new file mode 100644 index 0000000..f73c889 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Errors from writing a canonical `.ti3` dataset. +public enum CGATSWriterError: Error, Equatable { + case noSamples + case missingRequiredField(String) + case invalidValue(field: String, value: String) +} + +/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text. +public enum CGATSWriter { + + public static func write(_ dataset: CGATSDataset) throws -> String { + guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else { + throw CGATSWriterError.noSamples + } + + var lines = [String]() + + // Header + lines.append(dataset.format.rawValue) + lines.append("") + + lines.append("DESCRIPTOR \"ICCery CGATS export\"") + if let colorRep = dataset.colorRep { + lines.append("COLOR_REP \"\(colorRep)\"") + } + if let deviceClass = dataset.deviceClass { + lines.append("DEVICE_CLASS \"\(deviceClass)\"") + } + if let instrument = dataset.targetInstrument { + lines.append("TARGET_INSTRUMENT \"\(instrument)\"") + } + + lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)") + lines.append("NUMBER_OF_SETS \(dataset.samples.count)") + lines.append("") + + lines.append("BEGIN_DATA_FORMAT") + lines.append(dataset.fieldNames.joined(separator: "\t")) + lines.append("END_DATA_FORMAT") + lines.append("") + + lines.append("BEGIN_DATA") + for sample in dataset.samples { + let row = try dataset.fieldNames.map { field in + guard let raw = sample.values[field], !raw.isEmpty else { + throw CGATSWriterError.missingRequiredField(field) + } + // Normalize numeric fields to a compact decimal. + if isNumeric(field) { + return normalizedNumber(raw) + } + return raw + } + lines.append(row.joined(separator: "\t")) + } + lines.append("END_DATA") + + return lines.joined(separator: "\n") + "\n" + } + + public static func write(_ dataset: CGATSDataset, to url: URL) throws { + let text = try write(dataset) + try text.write(to: url, atomically: true, encoding: .utf8) + } + + // MARK: - Internals + + private static func isNumeric(_ field: String) -> Bool { + let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"] + return !nonNumeric.contains(field) + } + + private static func normalizedNumber(_ raw: String) -> String { + guard let number = Double(raw) else { return raw } + if number == floor(number) { + return String(format: "%.0f", number) + } + return String(format: "%.4f", number) + } +} diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index aeb404c..f32dfef 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -68,6 +68,8 @@ enum UITestHooks { static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") } /// `select_directory` result (working-directory browse). static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") } + /// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`). + static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") } /// Preset import file. static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } /// Preset export destination. diff --git a/Sources/ICCery/Stage1View.swift b/Sources/ICCery/Stage1View.swift index 1db8cb3..18b6358 100644 --- a/Sources/ICCery/Stage1View.swift +++ b/Sources/ICCery/Stage1View.swift @@ -87,9 +87,8 @@ struct Stage1View: View { Button("Working Dir…") { workflow.browseForWorkingDirectory() } Button("Open Existing…") { workflow.openExistingTarget() } .accessibilityIdentifier("btnOpenExisting") - Button("Import Dataset…") { /* CGATS import — #94, later */ } + Button("Import Dataset…") { workflow.importMeasurementDataset() } .accessibilityIdentifier("btn-import-dataset") - .disabled(true) } Text(workflow.targetDirectory?.path ?? "No working directory selected") .font(.caption) diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index be5e55c..57873a2 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -243,6 +243,43 @@ final class TargetWorkflowViewModel { // MARK: - Issue 8: resume an existing target + /// `#btn-import-dataset` — open a measured dataset, write a canonical + /// `.ti3` to the working directory, and set the target (issue #30). + func importMeasurementDataset() { + let url = UITestHooks.isEnabled + ? UITestHooks.datasetImportURL + : fileDialogs.selectDatasetFile() + guard let url else { return } + + do { + let dataset = try CGATSParser.parse(url: url) + guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else { + wizard.showNotice("Choose a working directory before importing.", kind: .warning) + return + } + + let stem = url.deletingPathExtension().lastPathComponent + let output = directory.appendingPathComponent("\(stem).ti3") + try CGATSWriter.write(dataset, to: output) + + wizard.setTarget(basename: stem, workingDirectory: directory) + wizard.refreshGating() + wizard.showNotice("Imported \(dataset.samples.count) patches from \(url.lastPathComponent)") + + if wizard.isUnlocked(.verifyInstall) { + wizard.go(to: .verifyInstall) + } else if wizard.isUnlocked(.buildProfile) { + wizard.go(to: .buildProfile) + } else { + wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning) + } + } catch let error as CGATSParseError { + wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error) + } catch { + wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error) + } + } + /// `#btnOpenExisting` — open `.ti1`/`.ti2` (open dialog, #103). /// `.ti1` → Stage 2; `.ti2` → Stage 3 with the resume notice, but /// only when the sibling `.ti1` exists so the artefact gate holds. diff --git a/Tests/ICCeryCoreTests/CGATSParserTests.swift b/Tests/ICCeryCoreTests/CGATSParserTests.swift new file mode 100644 index 0000000..a5e9a74 --- /dev/null +++ b/Tests/ICCeryCoreTests/CGATSParserTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("CGATS Parser & Writer") +struct CGATSParserTests { + + private static let canonicalCTI3 = """ + CTI3 + DESCRIPTOR "Sample target" + COLOR_REP "RGB" + DEVICE_CLASS "DISPLAY" + NUMBER_OF_FIELDS 11 + NUMBER_OF_SETS 2 + BEGIN_DATA_FORMAT + SAMPLE_ID\tSAMPLE_LOC\tRGB_R\tRGB_G\tRGB_B\tXYZ_X\tXYZ_Y\tXYZ_Z\tLAB_L\tLAB_A\tLAB_B + END_DATA_FORMAT + BEGIN_DATA + 1\tA1\t50.0\t0.0\t0.0\t20.0\t10.0\t5.0\t50.0\t60.0\t30.0 + 2\tA2\t0.0\t50.0\t0.0\t10.0\t30.0\t5.0\t60.0\t-50.0\t40.0 + END_DATA + """ + + @Test("Parses CTI3 with canonical field names") + func parseCTI3() throws { + let dataset = try CGATSParser.parse(Self.canonicalCTI3) + #expect(dataset.format == .cti3) + #expect(dataset.samples.count == 2) + #expect(dataset.colorRep == "RGB") + #expect(dataset.deviceClass == "DISPLAY") + #expect(dataset.samples[0].id == "1") + #expect(dataset.samples[0].loc == "A1") + #expect(dataset.samples[1].values["RGB_G"] == "50.0000") + } + + @Test("Round-trips parse, write, reparse") + func roundTrip() throws { + let first = try CGATSParser.parse(Self.canonicalCTI3) + let text = try CGATSWriter.write(first) + let second = try CGATSParser.parse(text) + #expect(second.format == first.format) + #expect(second.samples.count == first.samples.count) + #expect(second.colorRep == first.colorRep) + #expect(second.deviceClass == first.deviceClass) + } + + @Test("Parses CSV with comma delimiters") + func parseCSV() throws { + let csv = """ + SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B + 1,A1,50,0,0,20,10,5,50,60,30 + 2,A2,0,50,0,10,30,5,60,-50,40 + """ + let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv")) + #expect(dataset.format == .csv) + #expect(dataset.samples.count == 2) + #expect(dataset.samples[0].values["RGB_R"] == "50.0000") + } + + @Test("Converts 0-255 device values to 0-100") + func converts255To100() throws { + let rgb = """ + CTI3 + COLOR_REP RGB + NUMBER_OF_FIELDS 6 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y + END_DATA_FORMAT + BEGIN_DATA + 1 255 128 0 50 25 + END_DATA + """ + let dataset = try CGATSParser.parse(rgb) + #expect(dataset.samples[0].values["RGB_R"] == "100.0000") + #expect(dataset.samples[0].values["RGB_G"] == "50.1961") + } + + @Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing") + func synthesizesMetadata() throws { + let cmyk = """ + CTI3 + NUMBER_OF_FIELDS 6 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K LAB_L + END_DATA_FORMAT + BEGIN_DATA + 1 50 50 50 50 50 + END_DATA + """ + let dataset = try CGATSParser.parse(cmyk) + #expect(dataset.colorRep == "CMYK") + #expect(dataset.deviceClass == "PRINTER") + } + + @Test("Rejects empty file") + func rejectsEmpty() { + #expect(throws: (any Error).self) { + _ = try CGATSParser.parse("") + } + } + + @Test("Rejects malformed arity") + func rejectsArity() { + let bad = """ + CTI3 + NUMBER_OF_FIELDS 2 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R + END_DATA_FORMAT + BEGIN_DATA + 1 + END_DATA + """ + #expect(throws: (any Error).self) { + _ = try CGATSParser.parse(bad) + } + } + + @Test("Writer emits valid .ti3 with tabs and required keywords") + func writerFormat() throws { + let dataset = try CGATSParser.parse(Self.canonicalCTI3) + let text = try CGATSWriter.write(dataset) + #expect(text.contains("CTI3")) + #expect(text.contains("BEGIN_DATA_FORMAT")) + #expect(text.contains("BEGIN_DATA")) + #expect(text.contains("END_DATA")) + #expect(text.contains("COLOR_REP")) + #expect(text.contains("DEVICE_CLASS")) + #expect(text.contains("\t")) + } +} diff --git a/Tests/ICCeryUITests/Milestone6CGATSUITests.swift b/Tests/ICCeryUITests/Milestone6CGATSUITests.swift new file mode 100644 index 0000000..ceb820a --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6CGATSUITests.swift @@ -0,0 +1,64 @@ +import Foundation +import XCTest + +/// Milestone 6 CGATS import UI tests (issue #30). +@MainActor +final class Milestone6CGATSUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var datasetURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-cgats-ui-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) + + let csv = """ + SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B + 1,A1,50,0,0,20,10,5,50,60,30 + 2,A2,0,50,0,10,30,5,60,-50,40 + """ + datasetURL = testRoot.appendingPathComponent("imported.csv") + try csv.write(to: datasetURL, atomically: true, encoding: .utf8) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_WORKDIR": testRoot.path, + "ICCERY_TEST_DATASET_IMPORT": datasetURL.path + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + } + + /// `Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1` + /// Must fail if import presents a save panel or a `.ti1` filter. + func testImportUsesOpenPanelNotSaveTi1() throws { + app.launch() + app.activate() + + XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10)) + app.buttons["btn-import-dataset"].click() + + // No save panel should appear; the open panel is stubbed under UI testing. + let savePanel = app.sheets.firstMatch + XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.") + + // The dataset should be accepted and the user should advance to Stage 4. + _ = app.otherElements["stage-4"].waitForExistence(timeout: 10) + XCTAssertTrue(app.otherElements["stage-4"].exists) + + // The canonical .ti3 should be written next to the source file. + let ti3URL = testRoot.appendingPathComponent("imported.ti3") + XCTAssertTrue(FileManager.default.fileExists(atPath: ti3URL.path)) + } +} -- 2.39.5