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/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift index 48303ab..d40b262 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift @@ -15,14 +15,26 @@ public enum ArtefactFiles { try Data(contentsOf: url).base64EncodedString() } - /// `get_app_info` — version + build for the About dialog. + /// `get_app_info` — version, build, and build date for the About dialog. public static func appInfo( bundle: Bundle = .main - ) -> (version: String, build: String) { + ) -> (version: String, build: String, buildDate: String) { let info = bundle.infoDictionary ?? [:] - return ( - info["CFBundleShortVersionString"] as? String ?? "0.0.0", - info["CFBundleVersion"] as? String ?? "0" - ) + let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0" + let build = info["CFBundleVersion"] as? String ?? "0" + + let url = bundle.executableURL ?? bundle.bundleURL + let buildDate: String + if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let date = values.contentModificationDate { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + buildDate = formatter.string(from: date) + } else { + buildDate = "Unknown" + } + + return (version, build, buildDate) } } diff --git a/Sources/ICCery/AboutView.swift b/Sources/ICCery/AboutView.swift new file mode 100644 index 0000000..6396f8f --- /dev/null +++ b/Sources/ICCery/AboutView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import ICCeryCore + +/// About dialog for ICCery (issue #31, docs/21 §Modals). +struct AboutView: View { + let onClose: () -> Void + + private let info = ArtefactFiles.appInfo() + + var body: some View { + VStack(spacing: 20) { + Image("ICCery-logo") + .resizable() + .scaledToFit() + .frame(height: 64) + + Text("ICCery") + .font(.title) + .foregroundStyle(Theme.text) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Version:") + .foregroundStyle(.secondary) + Text(info.version) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("aboutVersion") + } + HStack { + Text("Build:") + .foregroundStyle(.secondary) + Text(info.build) + .foregroundStyle(Theme.text) + } + HStack { + Text("Build date:") + .foregroundStyle(.secondary) + Text(info.buildDate) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("aboutBuildDate") + } + } + .font(.callout) + + Text("Native macOS printer profiling workstation.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Close") { + onClose() + } + .controlSize(.large) + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("closeAboutBtn") + } + .padding(32) + .frame(width: 360) + .background(Theme.panel) + .accessibilityIdentifier("aboutDialog") + } +} 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/HelpOverlayView.swift b/Sources/ICCery/HelpOverlayView.swift new file mode 100644 index 0000000..99574b5 --- /dev/null +++ b/Sources/ICCery/HelpOverlayView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Reusable help overlay badge that does not reflow layout (#171). +/// +/// When `showing` is `true`, a small indicator is rendered as an overlay at the +/// top-trailing corner of the wrapped view. The native `.help` tooltip is always +/// available on hover, so the overlay is purely a visual cue in help mode. +struct HelpOverlay: ViewModifier { + let text: String + @Binding var showing: Bool + + func body(content: Content) -> some View { + content + .help(text) + .overlay(alignment: .topTrailing) { + if showing { + Image(systemName: "questionmark.circle.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Theme.accent) + .offset(x: 8, y: -8) + } + } + } +} + +extension View { + /// Adds a non-reflowing help overlay to the view. + func helpOverlay(_ text: String, showing: Binding) -> some View { + modifier(HelpOverlay(text: text, showing: showing)) + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 48d1ff2..501f8f1 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -8,6 +8,7 @@ struct RootView: View { @Bindable var workflow: TargetWorkflowViewModel @State private var showingSettings = false @State private var showingAbout = false + @State private var showingAllHelp = false private var model: WizardViewModel { workflow.wizard } @@ -16,7 +17,8 @@ struct RootView: View { SidebarView( workflow: workflow, onOpenSettings: { showingSettings = true }, - onOpenAbout: { showingAbout = true } + onOpenAbout: { showingAbout = true }, + showingAllHelp: $showingAllHelp ) Rectangle() @@ -48,10 +50,8 @@ struct RootView: View { .sheet(isPresented: $workflow.showingManagePresets) { ManagePresetsDialog(workflow: workflow) } - .alert("ICCery 2.0.0", isPresented: $showingAbout) { - Button("OK") {} - } message: { - Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.") + .sheet(isPresented: $showingAbout) { + AboutView { showingAbout = false } } } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index f2ed065..54268fc 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -7,6 +7,7 @@ struct SidebarView: View { @Bindable var workflow: TargetWorkflowViewModel var onOpenSettings: () -> Void var onOpenAbout: () -> Void + @Binding var showingAllHelp: Bool private var model: WizardViewModel { workflow.wizard } @@ -22,12 +23,20 @@ struct SidebarView: View { Image(systemName: "gearshape") } .buttonStyle(.plain) - .help("Settings") + .helpOverlay("Open the Settings dialog.", showing: $showingAllHelp) + .accessibilityIdentifier("openSettingsBtn") Button(action: onOpenAbout) { Image(systemName: "info.circle") } .buttonStyle(.plain) - .help("About ICCery") + .helpOverlay("Open the About dialog.", showing: $showingAllHelp) + .accessibilityIdentifier("openAboutBtn") + Button(action: { showingAllHelp.toggle() }) { + Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle") + } + .buttonStyle(.plain) + .help("Toggle help overlays") + .accessibilityIdentifier("btnToggleAllHelp") } .padding(12) 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/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift new file mode 100644 index 0000000..c6c2b03 --- /dev/null +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -0,0 +1,72 @@ +import XCTest + +/// About and help chrome UI tests (issue #31). +@MainActor +final class AboutHelpUITests: XCTestCase { + + private var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchEnvironment = ["ICCERY_UI_TESTING": "1"] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + } + + private func element(_ id: String) -> XCUIElement { + app.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + func testAboutDialogShowsVersionAndBuildDate() throws { + app.launch() + app.activate() + + let openAbout = app.buttons["openAboutBtn"] + XCTAssertTrue(openAbout.waitForExistence(timeout: 10)) + openAbout.click() + + _ = waitFor("aboutDialog", timeout: 10) + XCTAssertTrue(element("aboutVersion").exists) + XCTAssertTrue(element("aboutBuildDate").exists) + + let close = app.buttons["closeAboutBtn"] + XCTAssertTrue(close.exists) + close.click() + + XCTAssertFalse(element("aboutDialog").exists) + } + + func testHelpOverlaysDoNotChangeSidebarHeight() throws { + app.launch() + app.activate() + + let toggle = app.buttons["btnToggleAllHelp"] + XCTAssertTrue(toggle.waitForExistence(timeout: 10)) + + let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element + let before = sidebar.frame + + toggle.click() + let after = sidebar.frame + + XCTAssertEqual(before.size.height, after.size.height, + "Toggling global help must not reflow the sidebar height.") + XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists) + } +} 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)) + } +}