Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d78957d7a | ||
|
|
99c0d7e83d |
@@ -0,0 +1,218 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The `.icceryproj` file — a JSON **index** over a basename + working
|
||||||
|
/// directory + optional media recipe/preset binding + last verification
|
||||||
|
/// snapshot (issue #149, docs/06 §Project file).
|
||||||
|
///
|
||||||
|
/// The project is never a second source of truth: artefact gating stays
|
||||||
|
/// on disk (`ArtefactProbe`), and `wizard_state.json` continues to
|
||||||
|
/// persist the live session. snake_case keys match the v1 schema;
|
||||||
|
/// `schema_version != 1` is a hard decode error — v2 fields are never
|
||||||
|
/// partially decoded.
|
||||||
|
public struct ICCeryProject: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The only schema version this build reads and writes.
|
||||||
|
public static let currentSchemaVersion = 1
|
||||||
|
|
||||||
|
public var schemaVersion: Int
|
||||||
|
public var name: String
|
||||||
|
public var notes: String
|
||||||
|
/// Run name without extension — never `CAL_`-persisted (#60/R11).
|
||||||
|
public var basename: String
|
||||||
|
/// Absolute working directory holding the artefacts (#59).
|
||||||
|
public var cwd: String
|
||||||
|
/// May differ from `basename` after a `.ti3` import (#94).
|
||||||
|
public var profileBasename: String?
|
||||||
|
/// CUPS queue id, when a printer was selected.
|
||||||
|
public var printerID: String?
|
||||||
|
public var printerDisplayName: String?
|
||||||
|
/// `MediaRecipe.id` — optional; ignored when the library file is
|
||||||
|
/// absent or the id is unknown (soft-dependency, #146).
|
||||||
|
public var mediaRecipeID: String?
|
||||||
|
public var presetID: String?
|
||||||
|
/// Absolute `.cal` path stored verbatim; `nil` = none.
|
||||||
|
public var calibrationURL: String?
|
||||||
|
public var lastVerification: VerificationSnapshot?
|
||||||
|
public var updated: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
schemaVersion: Int = ICCeryProject.currentSchemaVersion,
|
||||||
|
name: String = "",
|
||||||
|
notes: String = "",
|
||||||
|
basename: String,
|
||||||
|
cwd: String,
|
||||||
|
profileBasename: String? = nil,
|
||||||
|
printerID: String? = nil,
|
||||||
|
printerDisplayName: String? = nil,
|
||||||
|
mediaRecipeID: String? = nil,
|
||||||
|
presetID: String? = nil,
|
||||||
|
calibrationURL: String? = nil,
|
||||||
|
lastVerification: VerificationSnapshot? = nil,
|
||||||
|
updated: Date = Date()
|
||||||
|
) {
|
||||||
|
self.schemaVersion = schemaVersion
|
||||||
|
self.name = name
|
||||||
|
self.notes = notes
|
||||||
|
self.basename = basename
|
||||||
|
self.cwd = cwd
|
||||||
|
self.profileBasename = profileBasename
|
||||||
|
self.printerID = printerID
|
||||||
|
self.printerDisplayName = printerDisplayName
|
||||||
|
self.mediaRecipeID = mediaRecipeID
|
||||||
|
self.presetID = presetID
|
||||||
|
self.calibrationURL = calibrationURL
|
||||||
|
self.lastVerification = lastVerification
|
||||||
|
self.updated = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion = "schema_version"
|
||||||
|
case name, notes, basename, cwd
|
||||||
|
case profileBasename = "profile_basename"
|
||||||
|
case printerID = "printer_id"
|
||||||
|
case printerDisplayName = "printer_display_name"
|
||||||
|
case mediaRecipeID = "media_recipe_id"
|
||||||
|
case presetID = "preset_id"
|
||||||
|
case calibrationURL = "calibration_url"
|
||||||
|
case lastVerification = "last_verification"
|
||||||
|
case updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict decode: `schema_version` is required and must equal 1 —
|
||||||
|
/// anything else throws before a single v2 field is read. Required
|
||||||
|
/// strings (`basename`, `cwd`) must be present; optionals default.
|
||||||
|
/// Unknown keys are ignored.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let version = try c.decode(Int.self, forKey: .schemaVersion)
|
||||||
|
guard version == ICCeryProject.currentSchemaVersion else {
|
||||||
|
throw ValidationError.unsupportedSchema(version)
|
||||||
|
}
|
||||||
|
schemaVersion = version
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
||||||
|
basename = try c.decode(String.self, forKey: .basename)
|
||||||
|
cwd = try c.decode(String.self, forKey: .cwd)
|
||||||
|
profileBasename = try c.decodeIfPresent(String.self, forKey: .profileBasename)
|
||||||
|
printerID = try c.decodeIfPresent(String.self, forKey: .printerID)
|
||||||
|
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName)
|
||||||
|
mediaRecipeID = try c.decodeIfPresent(String.self, forKey: .mediaRecipeID)
|
||||||
|
presetID = try c.decodeIfPresent(String.self, forKey: .presetID)
|
||||||
|
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
|
||||||
|
lastVerification = try c.decodeIfPresent(VerificationSnapshot.self, forKey: .lastVerification)
|
||||||
|
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ValidationError: LocalizedError, Equatable {
|
||||||
|
case unsupportedSchema(Int)
|
||||||
|
case emptyBasename
|
||||||
|
case invalidBasename(String)
|
||||||
|
case emptyCwd
|
||||||
|
case unsafeCwd(String)
|
||||||
|
case invalidCalibrationURL(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsupportedSchema:
|
||||||
|
return "This project file is not schema 1."
|
||||||
|
case .emptyBasename:
|
||||||
|
return "A project needs a target basename."
|
||||||
|
case .invalidBasename(let v):
|
||||||
|
return "Illegal project basename \"\(v)\"."
|
||||||
|
case .emptyCwd:
|
||||||
|
return "A project needs a working folder."
|
||||||
|
case .unsafeCwd(let v):
|
||||||
|
return "cwd must be an absolute path, got \"\(v)\"."
|
||||||
|
case .invalidCalibrationURL(let v):
|
||||||
|
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the index fields. Empty basename/cwd refuse (#59/#60);
|
||||||
|
/// basename still rejects `/`, `\`, `..`; cwd must be absolute. An
|
||||||
|
/// empty `name` falls back to the basename.
|
||||||
|
@discardableResult
|
||||||
|
public func validated() throws -> ICCeryProject {
|
||||||
|
var p = self
|
||||||
|
p.basename = basename.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !p.basename.isEmpty else { throw ValidationError.emptyBasename }
|
||||||
|
guard PathSecurity.isValidBasename(p.basename) else {
|
||||||
|
throw ValidationError.invalidBasename(p.basename)
|
||||||
|
}
|
||||||
|
let trimmedCwd = p.cwd.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedCwd.isEmpty else { throw ValidationError.emptyCwd }
|
||||||
|
guard trimmedCwd.hasPrefix("/"), !trimmedCwd.contains("\0") else {
|
||||||
|
throw ValidationError.unsafeCwd(p.cwd)
|
||||||
|
}
|
||||||
|
p.cwd = trimmedCwd
|
||||||
|
if p.name.isEmpty { p.name = p.basename }
|
||||||
|
if let cal = p.calibrationURL, !cal.isEmpty {
|
||||||
|
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
|
||||||
|
throw ValidationError.invalidCalibrationURL(cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a `.icceryproj` file. Throws `unsupportedSchema` for
|
||||||
|
/// `schema_version != 1` and the decode/validation error otherwise;
|
||||||
|
/// callers must leave live state untouched on failure.
|
||||||
|
public static func load(from url: URL) throws -> ICCeryProject {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return try decoder.decode(ICCeryProject.self, from: data).validated()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic `.tmp` + rename write via `AtomicFileWriter` (#213).
|
||||||
|
/// Validation runs first — a refused project never touches disk.
|
||||||
|
public func save(to url: URL) throws {
|
||||||
|
let encoder = JSONEncoder.icceryPretty(dateEncoding: .iso8601)
|
||||||
|
try AtomicFileWriter.write(try encoder.encode(validated()), to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last `VerificationRecord` frozen into the project file — notes
|
||||||
|
/// only, never gating truth.
|
||||||
|
public struct VerificationSnapshot: Codable, Equatable, Sendable {
|
||||||
|
public var date: Date
|
||||||
|
public var avgDE00: Double
|
||||||
|
public var maxDE00: Double
|
||||||
|
/// `VerificationStatus.rawValue`.
|
||||||
|
public var status: String
|
||||||
|
public var profileFilename: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
date: Date,
|
||||||
|
avgDE00: Double,
|
||||||
|
maxDE00: Double,
|
||||||
|
status: String,
|
||||||
|
profileFilename: String
|
||||||
|
) {
|
||||||
|
self.date = date
|
||||||
|
self.avgDE00 = avgDE00
|
||||||
|
self.maxDE00 = maxDE00
|
||||||
|
self.status = status
|
||||||
|
self.profileFilename = profileFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(record: VerificationRecord) {
|
||||||
|
self.init(
|
||||||
|
date: record.timestamp,
|
||||||
|
avgDE00: record.avgDE,
|
||||||
|
maxDE00: record.maxDE,
|
||||||
|
status: record.status.rawValue,
|
||||||
|
profileFilename: record.profileName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case avgDE00 = "avg_de00"
|
||||||
|
case maxDE00 = "max_de00"
|
||||||
|
case status
|
||||||
|
case profileFilename = "profile_filename"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `Save Report…` — a one-page UTF-8 Markdown summary written next to
|
||||||
|
/// the artefacts as `{basename}-report.md` (issue #149). Generated
|
||||||
|
/// output, safe to overwrite. Filenames only — never absolute paths —
|
||||||
|
/// and no HTML.
|
||||||
|
public enum ProjectReport {
|
||||||
|
|
||||||
|
/// `{cwd}/{basename}-report.md`.
|
||||||
|
public static func url(for project: ICCeryProject) -> URL {
|
||||||
|
URL(fileURLWithPath: project.cwd, isDirectory: true)
|
||||||
|
.appendingPathComponent("\(project.basename)-report.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the report. `artefacts` is the live probe result so the
|
||||||
|
/// checklist shows what is actually on disk, not what the project
|
||||||
|
/// JSON claims (R18).
|
||||||
|
public static func markdown(
|
||||||
|
project: ICCeryProject,
|
||||||
|
recipeName: String?,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) -> String {
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append("# \(project.name)")
|
||||||
|
lines.append("")
|
||||||
|
if let printer = project.printerDisplayName ?? project.printerID,
|
||||||
|
!printer.isEmpty {
|
||||||
|
lines.append("- **Printer:** \(printer)")
|
||||||
|
}
|
||||||
|
if let recipeName, !recipeName.isEmpty {
|
||||||
|
lines.append("- **Media recipe:** \(recipeName)")
|
||||||
|
}
|
||||||
|
if let preset = project.presetID, !preset.isEmpty {
|
||||||
|
lines.append("- **Preset:** \(preset)")
|
||||||
|
}
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## Artefacts")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| File | Status |")
|
||||||
|
lines.append("|------|--------|")
|
||||||
|
lines.append(row("\(project.basename).ti1", exists: artefacts.stage1Complete))
|
||||||
|
lines.append(row("\(project.basename).ti2", exists: artefacts.stage2Complete))
|
||||||
|
lines.append(row("\(project.basename).ti3", exists: artefacts.stage3Complete))
|
||||||
|
let profileName = artefacts.profilePath?.lastPathComponent
|
||||||
|
?? "\(project.basename).\(ArtefactProbe.defaultProfileExtension)"
|
||||||
|
lines.append(row(profileName, exists: artefacts.stage4Complete))
|
||||||
|
if let gam = artefacts.gamPath {
|
||||||
|
lines.append(row(gam.lastPathComponent, exists: true))
|
||||||
|
}
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if let verification = project.lastVerification {
|
||||||
|
lines.append("## Last verification")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"- avg ΔE₀₀ \(f(verification.avgDE00)), max ΔE₀₀ \(f(verification.maxDE00))"
|
||||||
|
+ " (\(verification.status))")
|
||||||
|
lines.append("- Profile: \(verification.profileFilename)")
|
||||||
|
lines.append("- Date: \(ISO8601DateFormatter().string(from: verification.date))")
|
||||||
|
lines.append("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !project.notes.isEmpty {
|
||||||
|
lines.append("## Notes")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(project.notes)
|
||||||
|
lines.append("")
|
||||||
|
}
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `{cwd}/{basename}-report.md` atomically, overwriting an
|
||||||
|
/// existing generated report.
|
||||||
|
@discardableResult
|
||||||
|
public static func write(
|
||||||
|
project: ICCeryProject,
|
||||||
|
recipeName: String?,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) throws -> URL {
|
||||||
|
let destination = url(for: project)
|
||||||
|
try AtomicFileWriter.write(
|
||||||
|
markdown(project: project, recipeName: recipeName, artefacts: artefacts),
|
||||||
|
to: destination)
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func row(_ filename: String, exists: Bool) -> String {
|
||||||
|
"| \(filename) | \(exists ? "exists" : "missing") |"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func f(_ value: Double) -> String {
|
||||||
|
String(format: "%.2f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One row in `recent_projects.json` — bookmark `Data` *and* the plain
|
||||||
|
/// absolute path so the entry still resolves when the bookmark can't.
|
||||||
|
public struct RecentProjectEntry: Codable, Equatable, Sendable, Identifiable {
|
||||||
|
public var name: String
|
||||||
|
/// Absolute path to the `.icceryproj` file.
|
||||||
|
public var path: String
|
||||||
|
/// File bookmark; optional — path is the fallback.
|
||||||
|
public var bookmark: Data?
|
||||||
|
public var updated: Date
|
||||||
|
|
||||||
|
public var id: String { path }
|
||||||
|
|
||||||
|
/// Stable digest of `path` for `projectRecent-{hash}` menu ids —
|
||||||
|
/// FNV-1a 64, stable across launches unlike `hashValue`.
|
||||||
|
public var bookmarkHash: String {
|
||||||
|
var hash: UInt64 = 0xcbf29ce484222325
|
||||||
|
for byte in path.utf8 {
|
||||||
|
hash ^= UInt64(byte)
|
||||||
|
hash &*= 0x100000001b3
|
||||||
|
}
|
||||||
|
return String(hash, radix: 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(name: String, path: String, bookmark: Data? = nil, updated: Date = Date()) {
|
||||||
|
self.name = name
|
||||||
|
self.path = path
|
||||||
|
self.bookmark = bookmark
|
||||||
|
self.updated = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, path, bookmark, updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recent-projects list (issue #149). Lives in
|
||||||
|
/// `AppPaths.appDataDir/recent_projects.json` — app data, never inside
|
||||||
|
/// the project file (R12).
|
||||||
|
///
|
||||||
|
/// Cap 20, newest first, deduplicated by path. Entries whose file is
|
||||||
|
/// gone are dropped by `pruneMissing()` (called when the Open Recent
|
||||||
|
/// submenu builds). A corrupt file throws on load and is never
|
||||||
|
/// overwritten — the view model shows an empty list and keeps the
|
||||||
|
/// bytes (`.throwCorrupt`, R12).
|
||||||
|
public actor RecentProjectsStore {
|
||||||
|
|
||||||
|
/// Default cap.
|
||||||
|
public static let defaultCapacity = 20
|
||||||
|
|
||||||
|
/// Path to the JSON store.
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
/// In-memory cache, kept in sync with disk.
|
||||||
|
private var entries: [RecentProjectEntry] = []
|
||||||
|
|
||||||
|
/// Explicit load flag — an empty file is still "loaded".
|
||||||
|
private var loaded = false
|
||||||
|
|
||||||
|
private let capacity: Int
|
||||||
|
private let fileStore: JSONFileStore<[RecentProjectEntry]>
|
||||||
|
private let fileManager: FileManager
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL = AppPaths.appDataDir.appendingPathComponent("recent_projects.json"),
|
||||||
|
capacity: Int = defaultCapacity,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.capacity = capacity
|
||||||
|
self.fileManager = fileManager
|
||||||
|
self.fileStore = JSONFileStore(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { [] },
|
||||||
|
dateEncoding: .iso8601,
|
||||||
|
dateDecoding: .iso8601
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads entries from disk. Returns the existing cache if already
|
||||||
|
/// loaded. Throws when the file exists but cannot be parsed; the
|
||||||
|
/// existing file is never overwritten in that case.
|
||||||
|
public func load() throws -> [RecentProjectEntry] {
|
||||||
|
guard !loaded else { return entries }
|
||||||
|
guard fileManager.fileExists(atPath: url.path) else {
|
||||||
|
loaded = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
entries = try fileStore.load()
|
||||||
|
loaded = true
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all cached entries, newest first.
|
||||||
|
public func all() -> [RecentProjectEntry] {
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a project to the front, deduplicating by path and
|
||||||
|
/// trimming to `capacity`. Writes atomically.
|
||||||
|
///
|
||||||
|
/// Loads the existing list first and propagates any load error so
|
||||||
|
/// an unparseable file is never overwritten.
|
||||||
|
@discardableResult
|
||||||
|
public func add(url fileURL: URL, name: String) throws -> [RecentProjectEntry] {
|
||||||
|
try load()
|
||||||
|
let bookmark = try? fileURL.bookmarkData()
|
||||||
|
var updated = entries.filter { $0.path != fileURL.path }
|
||||||
|
updated.insert(
|
||||||
|
RecentProjectEntry(
|
||||||
|
name: name,
|
||||||
|
path: fileURL.path,
|
||||||
|
bookmark: bookmark,
|
||||||
|
updated: Date()),
|
||||||
|
at: 0)
|
||||||
|
if updated.count > capacity {
|
||||||
|
updated = Array(updated.prefix(capacity))
|
||||||
|
}
|
||||||
|
try fileStore.save(updated)
|
||||||
|
entries = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an entry by path and writes atomically. Returns false
|
||||||
|
/// when no entry with that path exists.
|
||||||
|
@discardableResult
|
||||||
|
public func remove(path: String) throws -> Bool {
|
||||||
|
try load()
|
||||||
|
let before = entries.count
|
||||||
|
let updated = entries.filter { $0.path != path }
|
||||||
|
guard updated.count != before else { return false }
|
||||||
|
try fileStore.save(updated)
|
||||||
|
entries = updated
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Clear Menu` — wipes the recents file only; `.icceryproj` files
|
||||||
|
/// are never deleted (R12).
|
||||||
|
public func clear() throws {
|
||||||
|
try load()
|
||||||
|
try fileStore.save([])
|
||||||
|
entries = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops entries whose file is gone and rewrites the store.
|
||||||
|
/// Called when the Open Recent submenu builds — missing files are
|
||||||
|
/// dropped there, not at launch (issue #149).
|
||||||
|
@discardableResult
|
||||||
|
public func pruneMissing() throws -> [RecentProjectEntry] {
|
||||||
|
try load()
|
||||||
|
let kept = entries.filter {
|
||||||
|
fileManager.fileExists(atPath: $0.path)
|
||||||
|
}
|
||||||
|
if kept.count != entries.count {
|
||||||
|
try fileStore.save(kept)
|
||||||
|
entries = kept
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ struct AppEnvironment: Sendable {
|
|||||||
let cupsService: CupsService
|
let cupsService: CupsService
|
||||||
let historyStore: VerificationHistoryStore
|
let historyStore: VerificationHistoryStore
|
||||||
let mediaStore: MediaLibraryStore
|
let mediaStore: MediaLibraryStore
|
||||||
|
let recentProjectsStore: RecentProjectsStore
|
||||||
|
|
||||||
static func live(
|
static func live(
|
||||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||||
@@ -47,7 +48,8 @@ struct AppEnvironment: Sendable {
|
|||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryDir: cupsDir),
|
binaryDir: cupsDir),
|
||||||
historyStore: VerificationHistoryStore(),
|
historyStore: VerificationHistoryStore(),
|
||||||
mediaStore: MediaLibraryStore()
|
mediaStore: MediaLibraryStore(),
|
||||||
|
recentProjectsStore: RecentProjectsStore()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,12 @@ enum UITestHooks {
|
|||||||
static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") }
|
static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") }
|
||||||
/// TIFF sample picker result (gamut sheet, #147). Unset → cancel.
|
/// TIFF sample picker result (gamut sheet, #147). Unset → cancel.
|
||||||
static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") }
|
static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") }
|
||||||
|
/// `selectProjectFile` result (`.icceryproj` open, #149). Unset → cancel.
|
||||||
|
static var projectOpenURL: URL? { url("ICCERY_TEST_PROJECT_OPEN") }
|
||||||
|
/// `selectProjectSavePath` result (`.icceryproj` save-as, #149).
|
||||||
|
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
|
||||||
|
/// Relocate-folder result when a project's `cwd` is missing (#149).
|
||||||
|
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
|
||||||
|
|
||||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,23 @@ final class FileDialogService {
|
|||||||
message: "Export this preset as JSON")
|
message: "Export this preset as JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `selectProjectFile` — open `.icceryproj` only (issue #149).
|
||||||
|
func selectProjectFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["icceryproj"], startingAt: start,
|
||||||
|
message: "Open ICCery Project",
|
||||||
|
title: "Open ICCery Project",
|
||||||
|
allowsOtherFileTypes: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectProjectSavePath` — save `.icceryproj`, suggested name
|
||||||
|
/// `{basename}.icceryproj` in the working folder (issue #149).
|
||||||
|
func selectProjectSavePath(
|
||||||
|
basename: String, startingAt start: URL? = nil
|
||||||
|
) -> URL? {
|
||||||
|
save(named: "\(basename).icceryproj", extensions: ["icceryproj"],
|
||||||
|
startingAt: start, message: "Save ICCery Project")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Internals (private — not a shared public picker API)
|
// MARK: - Internals (private — not a shared public picker API)
|
||||||
|
|
||||||
private func save(
|
private func save(
|
||||||
@@ -116,6 +133,7 @@ final class FileDialogService {
|
|||||||
extensions: [String],
|
extensions: [String],
|
||||||
startingAt start: URL?,
|
startingAt start: URL?,
|
||||||
message: String?,
|
message: String?,
|
||||||
|
title: String? = nil,
|
||||||
allowsOtherFileTypes: Bool = true
|
allowsOtherFileTypes: Bool = true
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
@@ -126,6 +144,7 @@ final class FileDialogService {
|
|||||||
panel.allowsOtherFileTypes = allowsOtherFileTypes
|
panel.allowsOtherFileTypes = allowsOtherFileTypes
|
||||||
panel.directoryURL = start
|
panel.directoryURL = start
|
||||||
if let message { panel.message = message }
|
if let message { panel.message = message }
|
||||||
|
if let title { panel.title = title }
|
||||||
return run(panel)
|
return run(panel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ struct ICCeryApp: App {
|
|||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
// Single-window app: no File > New window.
|
// Single-window app: the File menu carries the project
|
||||||
CommandGroup(replacing: .newItem) {}
|
// commands (issue #149). Replacing `.newItem` keeps
|
||||||
|
// SwiftUI's empty default New from stacking (R19 — the
|
||||||
|
// group is filled, so no second New appears).
|
||||||
|
CommandGroup(replacing: .newItem) {
|
||||||
|
ProjectCommands(workflow: workflow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,689 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Project file session (issue #149): `.icceryproj` open/save/new/close,
|
||||||
|
/// recents, the dirty flag, and the window title.
|
||||||
|
///
|
||||||
|
/// The project is an **index** — disk artefacts still own the stepper
|
||||||
|
/// (R18). Open writes `WizardState` through the normal setters and ends
|
||||||
|
/// in the same probe `windowDidBecomeKey` uses; it never auto-runs
|
||||||
|
/// `targen`/`colprof`/`chartread` and never unlocks a stage the disk
|
||||||
|
/// does not back.
|
||||||
|
@MainActor
|
||||||
|
final class ProjectSession: ObservableObject {
|
||||||
|
|
||||||
|
/// What to do once the dirty alert resolves.
|
||||||
|
enum PendingAction {
|
||||||
|
case new
|
||||||
|
case open(URL)
|
||||||
|
case close
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live session snapshot, compared against the bound project for
|
||||||
|
/// the dirty flag (title `•`, `btnProjectSave`).
|
||||||
|
private struct Snapshot: Equatable {
|
||||||
|
var basename: String
|
||||||
|
var cwd: String
|
||||||
|
var printerID: String?
|
||||||
|
var presetID: String?
|
||||||
|
var mediaRecipeID: String?
|
||||||
|
var calibrationURL: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
private let recentsStore: RecentProjectsStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Bound project file location; `nil` = no project.
|
||||||
|
@Published var projectURL: URL?
|
||||||
|
/// Last saved/opened project payload.
|
||||||
|
@Published var project: ICCeryProject?
|
||||||
|
/// Open Recent entries, newest first, missing files pruned.
|
||||||
|
@Published var recents: [RecentProjectEntry] = []
|
||||||
|
/// JSON claimed a finished profile but disk stops earlier —
|
||||||
|
/// `projectChipStale` + info banner (R18).
|
||||||
|
@Published var diskBehindNotes = false
|
||||||
|
/// `ICCery` / `ICCery — {name}` / `ICCery — {name} •`.
|
||||||
|
@Published var windowTitle = "ICCery"
|
||||||
|
/// Live session fields differ from the bound project — computed
|
||||||
|
/// fresh so decision paths (`requestNew`/`requestClose`) never see
|
||||||
|
/// a stale willSet value.
|
||||||
|
var isDirty: Bool {
|
||||||
|
guard let project else { return false }
|
||||||
|
return liveSnapshot() != snapshot(of: project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow state for RootView.
|
||||||
|
@Published var showingNewAlert = false
|
||||||
|
@Published var showingDirtyAlert = false
|
||||||
|
@Published var showingRelocateSheet = false
|
||||||
|
|
||||||
|
private var pendingAction: PendingAction?
|
||||||
|
/// Loaded project whose `cwd` is missing — relocate sheet payload.
|
||||||
|
@Published private(set) var pendingRelocate:
|
||||||
|
(project: ICCeryProject, url: URL)?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.environment = environment
|
||||||
|
self.recentsStore = environment.recentProjectsStore
|
||||||
|
refreshRecents()
|
||||||
|
|
||||||
|
// Dirty / title derive from live fields — observe each source;
|
||||||
|
// child VMs are not tracked through the parent's
|
||||||
|
// objectWillChange (R22-style weak sinks). `@Published` fires on
|
||||||
|
// willSet, so the recompute is deferred one main turn to read
|
||||||
|
// post-set values.
|
||||||
|
let wizard = workflow.wizard
|
||||||
|
let printSession = workflow.print
|
||||||
|
let media = workflow.media
|
||||||
|
let profile = workflow.profile
|
||||||
|
wizard.$basename.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
wizard.$workingDirectory.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
wizard.$printerName.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
printSession?.$selectedPrinter.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
workflow.$selectedPresetID.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
media?.$selectedRecipeID.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
profile.$calibrationFile.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
recomputeDerived()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { cancellables.removeAll() }
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
var isBound: Bool { project != nil }
|
||||||
|
|
||||||
|
/// A chartread / spotread / colprof child is live — project changes
|
||||||
|
/// wait for the instrument run to finish.
|
||||||
|
var childSessionLive: Bool {
|
||||||
|
workflow.measurement.isChartreadRunning
|
||||||
|
|| workflow.spotRead.isRunning
|
||||||
|
|| workflow.profile.isColprofRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basename eligible for persistence. A live `CAL_` stem resolves
|
||||||
|
/// only through the persisted original — never by stripping, so a
|
||||||
|
/// bare `CAL_` (Force-Quit anomaly, empty persisted original) is
|
||||||
|
/// refused rather than trusted (R11).
|
||||||
|
var resolvedBasename: String? {
|
||||||
|
let live = workflow.wizard.basename
|
||||||
|
guard CalibrationIdentity.isCalibration(live) else {
|
||||||
|
return live.isEmpty ? nil : live
|
||||||
|
}
|
||||||
|
let original = workflow.wizard.calibrationOriginalBasename
|
||||||
|
return original.isEmpty ? nil : original
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⌘S / `btnProjectSave` enable rule. A `CAL_` live basename stays
|
||||||
|
/// enabled so the refusal can show its banner.
|
||||||
|
var canSave: Bool {
|
||||||
|
isBound && !workflow.wizard.basename.isEmpty
|
||||||
|
&& workflow.wizard.workingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `menuProjectSaveAs` — no binding required.
|
||||||
|
var canSaveAs: Bool {
|
||||||
|
!workflow.wizard.basename.isEmpty
|
||||||
|
&& workflow.wizard.workingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `menuProjectReport` — needs a real basename and cwd on disk.
|
||||||
|
var canReport: Bool {
|
||||||
|
!workflow.wizard.basename.isEmpty
|
||||||
|
&& workflow.wizard.workingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func liveSnapshot() -> Snapshot {
|
||||||
|
Snapshot(
|
||||||
|
basename: resolvedBasename ?? "",
|
||||||
|
cwd: workflow.wizard.workingDirectory?.path ?? "",
|
||||||
|
printerID: workflow.print.selectedPrinter.isEmpty
|
||||||
|
? nil : workflow.print.selectedPrinter,
|
||||||
|
presetID: workflow.selectedPresetID == "none"
|
||||||
|
? nil : workflow.selectedPresetID,
|
||||||
|
mediaRecipeID: workflow.media.selectedRecipeID == "none"
|
||||||
|
? nil : workflow.media.selectedRecipeID,
|
||||||
|
calibrationURL: workflow.profile.calibrationFile.isEmpty
|
||||||
|
? nil : workflow.profile.calibrationFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func snapshot(of project: ICCeryProject) -> Snapshot {
|
||||||
|
Snapshot(
|
||||||
|
basename: project.basename,
|
||||||
|
cwd: project.cwd,
|
||||||
|
printerID: project.printerID,
|
||||||
|
presetID: project.presetID,
|
||||||
|
mediaRecipeID: project.mediaRecipeID,
|
||||||
|
calibrationURL: project.calibrationURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes the window title from live fields.
|
||||||
|
func recomputeDerived() {
|
||||||
|
if let project {
|
||||||
|
windowTitle = "ICCery — \(project.name)" + (isDirty ? " •" : "")
|
||||||
|
} else {
|
||||||
|
windowTitle = "ICCery"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defer the recompute one main turn — the Combine sinks fire on
|
||||||
|
/// willSet, before the changed property holds its new value.
|
||||||
|
private func scheduleRecompute() {
|
||||||
|
Task { @MainActor [weak self] in self?.recomputeDerived() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - New
|
||||||
|
|
||||||
|
/// ⌘N / `menuProjectNew`. Dirty sessions detour through the dirty
|
||||||
|
/// alert first; a live child gets a banner instead of the alert.
|
||||||
|
func requestNew() {
|
||||||
|
guard !childSessionLive else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Finish the instrument session before starting a project.",
|
||||||
|
kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isDirty {
|
||||||
|
pendingAction = .new
|
||||||
|
showingDirtyAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showingNewAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnProjectNewConfirm` — unbinds and clears the basename. Empty
|
||||||
|
/// is the legal "no target yet" state (#60); artefacts and
|
||||||
|
/// `media_library.json` are never deleted (R18).
|
||||||
|
func confirmNew() {
|
||||||
|
showingNewAlert = false
|
||||||
|
projectURL = nil
|
||||||
|
project = nil
|
||||||
|
pendingRelocate = nil
|
||||||
|
diskBehindNotes = false
|
||||||
|
workflow.wizard.basename = ""
|
||||||
|
workflow.targetBasename = ""
|
||||||
|
workflow.media.selectedRecipeID = "none"
|
||||||
|
workflow.wizard.sessionMode = .profile
|
||||||
|
// Re-probe — an empty basename locks stages 2–5.
|
||||||
|
workflow.wizard.windowDidBecomeKey()
|
||||||
|
recomputeDerived()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Open
|
||||||
|
|
||||||
|
/// ⌘O / `btnProjectOpen` — `selectProjectFile` (`.icceryproj`
|
||||||
|
/// only). Cancel is a no-op.
|
||||||
|
func requestOpen() {
|
||||||
|
guard !childSessionLive else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Finish the instrument session before opening a project.",
|
||||||
|
kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.projectOpenURL
|
||||||
|
: fileDialogs.selectProjectFile()
|
||||||
|
guard let url else { return }
|
||||||
|
if isDirty {
|
||||||
|
pendingAction = .open(url)
|
||||||
|
showingDirtyAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open from the recents submenu — same path, no open panel. A
|
||||||
|
/// missing file is dropped with a banner; no relocate is offered.
|
||||||
|
func openRecent(_ entry: RecentProjectEntry) {
|
||||||
|
guard !childSessionLive else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Finish the instrument session before opening a project.",
|
||||||
|
kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let url = URL(fileURLWithPath: entry.path)
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
Task { try? await recentsStore.remove(path: entry.path) }
|
||||||
|
refreshRecents()
|
||||||
|
workflow.wizard.showNotice("Project file is gone.", kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isDirty {
|
||||||
|
pendingAction = .open(url)
|
||||||
|
showingDirtyAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared open path — validates, routes to relocate when `cwd` is
|
||||||
|
/// gone, then applies. Failures leave live state untouched.
|
||||||
|
func open(_ url: URL) {
|
||||||
|
guard let loaded = loadProject(url) else { return }
|
||||||
|
apply(loaded, from: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Awaitable open for tests — identical to `open` but completes
|
||||||
|
/// after apply finishes.
|
||||||
|
func openAsync(_ url: URL) async {
|
||||||
|
guard let loaded = loadProject(url) else { return }
|
||||||
|
await applyAsync(loaded, from: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode + validate + cwd-existence check. Returns the project
|
||||||
|
/// ready to apply, or nil after presenting an error banner / the
|
||||||
|
/// relocate sheet.
|
||||||
|
private func loadProject(_ url: URL) -> ICCeryProject? {
|
||||||
|
let loaded: ICCeryProject
|
||||||
|
do {
|
||||||
|
loaded = try ICCeryProject.load(from: url)
|
||||||
|
} catch let error as ICCeryProject.ValidationError {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
error.errorDescription ?? "Could not open project.",
|
||||||
|
kind: .error)
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not open project: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
guard FileManager.default.fileExists(
|
||||||
|
atPath: loaded.cwd, isDirectory: &isDir), isDir.boolValue
|
||||||
|
else {
|
||||||
|
pendingRelocate = (loaded, url)
|
||||||
|
showingRelocateSheet = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync entry — the apply half runs in a Task so `media.apply`
|
||||||
|
/// (async) can run inside it.
|
||||||
|
private func apply(_ project: ICCeryProject, from url: URL) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
await self?.applyAsync(project, from: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply order (issue #149):
|
||||||
|
/// 1. cwd + basename on the wizard **and** Stage 1 fields.
|
||||||
|
/// 2. `mediaRecipeID` → `MediaLibraryViewModel.apply`; else
|
||||||
|
/// `presetID` → existing preset apply. Missing recipe is not
|
||||||
|
/// fatal — open still succeeds.
|
||||||
|
/// 3. Re-probe via the existing window-focus path (#151).
|
||||||
|
/// 4. Never auto-run `targen` / `colprof` / `chartread`.
|
||||||
|
private func applyAsync(_ project: ICCeryProject, from url: URL) async {
|
||||||
|
let cwdURL = URL(fileURLWithPath: project.cwd, isDirectory: true)
|
||||||
|
|
||||||
|
workflow.wizard.setTarget(
|
||||||
|
basename: project.basename, workingDirectory: cwdURL)
|
||||||
|
workflow.targetBasename = project.basename
|
||||||
|
workflow.targetDirectory = cwdURL
|
||||||
|
workflow.wizard.printerName = project.printerDisplayName
|
||||||
|
?? project.printerID
|
||||||
|
workflow.wizard.profileBasename = project.profileBasename
|
||||||
|
workflow.wizard.sessionMode = .profile
|
||||||
|
if let queue = project.printerID,
|
||||||
|
workflow.print.printers.contains(where: { $0.name == queue }) {
|
||||||
|
workflow.print.selectedPrinter = queue
|
||||||
|
}
|
||||||
|
|
||||||
|
var appliedRecipe = false
|
||||||
|
if let recipeID = project.mediaRecipeID {
|
||||||
|
if workflow.media.recipes.isEmpty {
|
||||||
|
await workflow.media.reloadAsync()
|
||||||
|
}
|
||||||
|
if let recipe = workflow.media.recipes
|
||||||
|
.first(where: { $0.id == recipeID }) {
|
||||||
|
appliedRecipe = await workflow.media.apply(recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !appliedRecipe, let presetID = project.presetID,
|
||||||
|
let preset = workflow.presets
|
||||||
|
.first(where: { $0.id == presetID }) {
|
||||||
|
workflow.applyPreset(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk owns the stepper — same probe window focus uses.
|
||||||
|
workflow.wizard.windowDidBecomeKey()
|
||||||
|
|
||||||
|
// JSON-vs-disk mismatch: notes say the profile is done but the
|
||||||
|
// artefacts stop earlier — index drift, not gating truth.
|
||||||
|
let artefacts = workflow.wizard.artefacts
|
||||||
|
if project.lastVerification != nil && !artefacts.stage4Complete {
|
||||||
|
let already = diskBehindNotes && projectURL == url
|
||||||
|
diskBehindNotes = true
|
||||||
|
if !already {
|
||||||
|
let last = artefacts.stage3Complete ? ".ti3"
|
||||||
|
: artefacts.stage2Complete ? ".ti2"
|
||||||
|
: artefacts.stage1Complete ? ".ti1" : nil
|
||||||
|
let detail = last.map { "artefacts on disk stop at \($0)." }
|
||||||
|
?? "no artefacts found on disk."
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Project notes say profile done; \(detail)",
|
||||||
|
kind: .info)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
diskBehindNotes = false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.project = project
|
||||||
|
projectURL = url
|
||||||
|
pushRecent(url: url, name: project.name)
|
||||||
|
recomputeDerived()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Relocate
|
||||||
|
|
||||||
|
/// `btnProjectRelocate` — pick a new cwd, rewrite the project file
|
||||||
|
/// atomically, then continue Apply.
|
||||||
|
func chooseRelocateFolder() {
|
||||||
|
guard let pending = pendingRelocate else { return }
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.projectRelocateURL
|
||||||
|
: fileDialogs.selectDirectory()
|
||||||
|
guard let url else { return }
|
||||||
|
var rewritten = pending.project
|
||||||
|
rewritten.cwd = url.path
|
||||||
|
rewritten.updated = Date()
|
||||||
|
do {
|
||||||
|
try rewritten.save(to: pending.url)
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not update project: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingRelocate = nil
|
||||||
|
showingRelocateSheet = false
|
||||||
|
apply(rewritten, from: pending.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnProjectRelocateCancel` — aborts the open; live session
|
||||||
|
/// unchanged.
|
||||||
|
func cancelRelocate() {
|
||||||
|
pendingRelocate = nil
|
||||||
|
showingRelocateSheet = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save / Save As
|
||||||
|
|
||||||
|
/// ⌘S / `btnProjectSave` — writes the live session into the bound
|
||||||
|
/// URL. A live `CAL_` stem refuses unless the persisted original
|
||||||
|
/// resolves; `CAL_` is never persisted (R11).
|
||||||
|
func saveProject() {
|
||||||
|
Task { @MainActor in _ = await saveProjectAsync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func saveProjectAsync() async -> Bool {
|
||||||
|
guard let url = projectURL, project != nil else { return false }
|
||||||
|
guard let basename = resolvedBasename else {
|
||||||
|
refuseUnsavable()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await write(to: url, basename: basename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⇧⌘S / `menuProjectSaveAs` — always shows the save picker, then
|
||||||
|
/// binds the chosen URL and pushes recents.
|
||||||
|
func saveProjectAs() {
|
||||||
|
guard let basename = resolvedBasename else {
|
||||||
|
refuseUnsavable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let cwd = workflow.wizard.workingDirectory else {
|
||||||
|
refuseUnsavable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.projectSaveURL
|
||||||
|
: fileDialogs.selectProjectSavePath(
|
||||||
|
basename: basename, startingAt: cwd)
|
||||||
|
guard let url else { return }
|
||||||
|
Task { @MainActor in _ = await write(to: url, basename: basename) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refuseUnsavable() {
|
||||||
|
if CalibrationIdentity.isCalibration(workflow.wizard.basename) {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Finish or exit calibration before saving a project.",
|
||||||
|
kind: .warning)
|
||||||
|
} else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Set a target basename and working folder before saving a project.",
|
||||||
|
kind: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the project payload from live session fields and writes
|
||||||
|
/// it atomically. On success binds `url`, refreshes recents, and
|
||||||
|
/// clears the stale-chip flag. Failure → error banner, bound URL
|
||||||
|
/// unchanged.
|
||||||
|
@discardableResult
|
||||||
|
private func write(to url: URL, basename: String) async -> Bool {
|
||||||
|
guard let cwd = workflow.wizard.workingDirectory,
|
||||||
|
!cwd.path.isEmpty else {
|
||||||
|
refuseUnsavable()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let stem = workflow.wizard.profileBasename ?? basename
|
||||||
|
let snapshot = await lastVerificationSnapshot(stem: stem)
|
||||||
|
?? project?.lastVerification
|
||||||
|
let queue = workflow.print.selectedPrinter
|
||||||
|
let display = workflow.print.printers
|
||||||
|
.first { $0.name == queue }?.displayName
|
||||||
|
?? workflow.wizard.printerName
|
||||||
|
let existing = project
|
||||||
|
let name = (existing?.name.isEmpty == false)
|
||||||
|
? existing!.name : basename
|
||||||
|
let updated = ICCeryProject(
|
||||||
|
name: name,
|
||||||
|
notes: existing?.notes ?? "",
|
||||||
|
basename: basename,
|
||||||
|
cwd: cwd.path,
|
||||||
|
profileBasename: workflow.wizard.profileBasename,
|
||||||
|
printerID: queue.isEmpty ? nil : queue,
|
||||||
|
printerDisplayName: display,
|
||||||
|
mediaRecipeID: workflow.media.selectedRecipeID == "none"
|
||||||
|
? nil : workflow.media.selectedRecipeID,
|
||||||
|
presetID: workflow.selectedPresetID == "none"
|
||||||
|
? nil : workflow.selectedPresetID,
|
||||||
|
calibrationURL: workflow.profile.calibrationFile.isEmpty
|
||||||
|
? nil : workflow.profile.calibrationFile,
|
||||||
|
lastVerification: snapshot,
|
||||||
|
updated: Date())
|
||||||
|
do {
|
||||||
|
try updated.save(to: url)
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not save project: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
project = updated
|
||||||
|
projectURL = url
|
||||||
|
diskBehindNotes = false
|
||||||
|
pushRecent(url: url, name: updated.name)
|
||||||
|
workflow.wizard.showNotice("Project saved: \(url.lastPathComponent)")
|
||||||
|
recomputeDerived()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last `VerificationHistoryStore` record for this profile stem,
|
||||||
|
/// if any — notes only.
|
||||||
|
private func lastVerificationSnapshot(
|
||||||
|
stem: String
|
||||||
|
) async -> VerificationSnapshot? {
|
||||||
|
guard let records = try? await environment.historyStore.load()
|
||||||
|
else { return nil }
|
||||||
|
guard let record = records.last(where: {
|
||||||
|
$0.profileName == stem || $0.profileName == workflow.wizard.basename
|
||||||
|
}) else { return nil }
|
||||||
|
return VerificationSnapshot(record: record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Close
|
||||||
|
|
||||||
|
/// `menuProjectClose` — unbinds; live basename/cwd/artefacts stay.
|
||||||
|
func requestClose() {
|
||||||
|
guard isBound else { return }
|
||||||
|
if isDirty {
|
||||||
|
pendingAction = .close
|
||||||
|
showingDirtyAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeProject()
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeProject() {
|
||||||
|
projectURL = nil
|
||||||
|
project = nil
|
||||||
|
diskBehindNotes = false
|
||||||
|
recomputeDerived()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dirty alert
|
||||||
|
|
||||||
|
/// `btnProjectDirtySave` / `btnProjectDirtyDiscard`. Save proceeds
|
||||||
|
/// to the pending action only when the write succeeded.
|
||||||
|
func resolveDirty(save: Bool) {
|
||||||
|
showingDirtyAlert = false
|
||||||
|
guard let action = pendingAction else { return }
|
||||||
|
if save {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if await self.saveProjectAsync() {
|
||||||
|
self.pendingAction = nil
|
||||||
|
self.proceed(with: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pendingAction = nil
|
||||||
|
proceed(with: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnProjectDirtyCancel` — abandons the pending action.
|
||||||
|
func cancelDirty() {
|
||||||
|
pendingAction = nil
|
||||||
|
showingDirtyAlert = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func proceed(with action: PendingAction) {
|
||||||
|
switch action {
|
||||||
|
case .new:
|
||||||
|
confirmNew()
|
||||||
|
case .open(let url):
|
||||||
|
open(url)
|
||||||
|
case .close:
|
||||||
|
closeProject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report
|
||||||
|
|
||||||
|
/// `menuProjectReport` — writes `{cwd}/{basename}-report.md`
|
||||||
|
/// atomically (generated; overwrites). No picker.
|
||||||
|
func saveReport() {
|
||||||
|
guard canReport, let cwd = workflow.wizard.workingDirectory else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let basename = workflow.wizard.basename
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let stem = self.workflow.wizard.profileBasename ?? basename
|
||||||
|
let snapshot = await self.lastVerificationSnapshot(stem: stem)
|
||||||
|
?? self.project?.lastVerification
|
||||||
|
let queue = self.workflow.print.selectedPrinter
|
||||||
|
let display = self.workflow.print.printers
|
||||||
|
.first { $0.name == queue }?.displayName
|
||||||
|
?? self.workflow.wizard.printerName
|
||||||
|
let recipeName = self.workflow.media.recipes
|
||||||
|
.first { $0.id == self.workflow.media.selectedRecipeID }?.name
|
||||||
|
let payload = ICCeryProject(
|
||||||
|
name: self.project?.name.isEmpty == false
|
||||||
|
? self.project!.name : basename,
|
||||||
|
notes: self.project?.notes ?? "",
|
||||||
|
basename: basename,
|
||||||
|
cwd: cwd.path,
|
||||||
|
profileBasename: self.workflow.wizard.profileBasename,
|
||||||
|
printerID: queue.isEmpty ? nil : queue,
|
||||||
|
printerDisplayName: display,
|
||||||
|
mediaRecipeID: self.workflow.media.selectedRecipeID == "none"
|
||||||
|
? nil : self.workflow.media.selectedRecipeID,
|
||||||
|
presetID: self.workflow.selectedPresetID == "none"
|
||||||
|
? nil : self.workflow.selectedPresetID,
|
||||||
|
calibrationURL: self.workflow.profile.calibrationFile.isEmpty
|
||||||
|
? nil : self.workflow.profile.calibrationFile,
|
||||||
|
lastVerification: snapshot,
|
||||||
|
updated: Date())
|
||||||
|
do {
|
||||||
|
let written = try ProjectReport.write(
|
||||||
|
project: payload,
|
||||||
|
recipeName: recipeName,
|
||||||
|
artefacts: self.workflow.wizard.artefacts)
|
||||||
|
self.workflow.wizard.showNotice(
|
||||||
|
"Wrote \(written.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
self.workflow.wizard.showNotice(
|
||||||
|
"Report failed: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recents / reveal
|
||||||
|
|
||||||
|
/// Rebuilds the recents list; entries whose file is gone are
|
||||||
|
/// dropped here (submenu build), not at launch. Corrupt → `[]`,
|
||||||
|
/// file kept (R12).
|
||||||
|
func refreshRecents() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
self.recents = try await self.recentsStore.pruneMissing()
|
||||||
|
} catch {
|
||||||
|
self.recents = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pushRecent(url: URL, name: String) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await self.recentsStore.add(url: url, name: name)
|
||||||
|
self.refreshRecents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `menuProjectRecentsClear` — wipes `recent_projects.json` only;
|
||||||
|
/// `.icceryproj` files are never deleted.
|
||||||
|
func clearRecents() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
try? await self?.recentsStore.clear()
|
||||||
|
self?.recents = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnProjectReveal` — reveals the bound **project file** in
|
||||||
|
/// Finder, not the cwd.
|
||||||
|
func revealInFinder() {
|
||||||
|
guard let projectURL else { return }
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([projectURL])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// File-menu commands for the project file (issue #149). Content of the
|
||||||
|
/// `CommandGroup(replacing: .newItem)` in `ICCeryApp` — split out so the
|
||||||
|
/// app body stays small (type-checker budget). Menu ids are distinct
|
||||||
|
/// from the sidebar chip ids (`menuProject*` vs `btnProject*`).
|
||||||
|
struct ProjectCommands: View {
|
||||||
|
@ObservedObject private var project: ProjectSession
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button("New Project") { project.requestNew() }
|
||||||
|
.keyboardShortcut("n")
|
||||||
|
.accessibilityIdentifier("menuProjectNew")
|
||||||
|
Button("Open Project…") { project.requestOpen() }
|
||||||
|
.keyboardShortcut("o")
|
||||||
|
.accessibilityIdentifier("menuProjectOpen")
|
||||||
|
Menu("Open Recent") {
|
||||||
|
ForEach(project.recents) { entry in
|
||||||
|
// Names render via Text only (#114); never the raw path.
|
||||||
|
Button(entry.name) { project.openRecent(entry) }
|
||||||
|
.accessibilityIdentifier("projectRecent-\(entry.bookmarkHash)")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("Clear Menu") { project.clearRecents() }
|
||||||
|
.accessibilityIdentifier("menuProjectRecentsClear")
|
||||||
|
}
|
||||||
|
.disabled(project.recents.isEmpty)
|
||||||
|
.accessibilityIdentifier("menuProjectRecents")
|
||||||
|
Divider()
|
||||||
|
Button("Save Project") { project.saveProject() }
|
||||||
|
.keyboardShortcut("s")
|
||||||
|
.disabled(!project.canSave)
|
||||||
|
.accessibilityIdentifier("menuProjectSave")
|
||||||
|
Button("Save Project As…") { project.saveProjectAs() }
|
||||||
|
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||||
|
.disabled(!project.canSaveAs)
|
||||||
|
.accessibilityIdentifier("menuProjectSaveAs")
|
||||||
|
Button("Save Report…") { project.saveReport() }
|
||||||
|
.disabled(!project.canReport)
|
||||||
|
.accessibilityIdentifier("menuProjectReport")
|
||||||
|
Divider()
|
||||||
|
Button("Close Project") { project.requestClose() }
|
||||||
|
.disabled(!project.isBound)
|
||||||
|
.accessibilityIdentifier("menuProjectClose")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact project footer at the bottom of the 270 pt sidebar (issue
|
||||||
|
/// #149) — never a fourth row of large buttons (R10).
|
||||||
|
struct ProjectChip: View {
|
||||||
|
@ObservedObject var project: ProjectSession
|
||||||
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if let bound = project.project {
|
||||||
|
Text(bound.name)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.accessibilityIdentifier("projectChipName")
|
||||||
|
Text(URL(fileURLWithPath: bound.cwd).lastPathComponent)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
// The raw path is a tooltip — never the window title.
|
||||||
|
.help(bound.cwd)
|
||||||
|
.accessibilityIdentifier("projectChipPath")
|
||||||
|
if project.diskBehindNotes {
|
||||||
|
Text("Disk behind project notes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.accessibilityIdentifier("projectChipStale")
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Show in Finder") { project.revealInFinder() }
|
||||||
|
.accessibilityIdentifier("btnProjectReveal")
|
||||||
|
Button("Save") { project.saveProject() }
|
||||||
|
.disabled(!project.canSave)
|
||||||
|
.accessibilityIdentifier("btnProjectSave")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("No project")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("projectChipName")
|
||||||
|
Spacer()
|
||||||
|
Button("Open…") { project.requestOpen() }
|
||||||
|
.controlSize(.small)
|
||||||
|
.accessibilityIdentifier("btnProjectOpen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||||
|
.fill(Theme.panel)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||||
|
.stroke(Theme.border)
|
||||||
|
)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("projectChip")
|
||||||
|
.helpOverlay(
|
||||||
|
"A project remembers printer, preset and folder. The stepper still follows files on disk.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `projectRelocateSheet` — shown when an opened project's `cwd` no
|
||||||
|
/// longer exists (issue #149). Choosing a folder rewrites `cwd` in the
|
||||||
|
/// project file atomically, then continues Apply; Cancel aborts the
|
||||||
|
/// open with live state untouched.
|
||||||
|
struct ProjectRelocateSheet: View {
|
||||||
|
@ObservedObject var project: ProjectSession
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Missing project folder")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Text(
|
||||||
|
"The folder for \(project.pendingRelocate?.project.name ?? "this project") is missing. Choose a new working folder."
|
||||||
|
)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { project.cancelRelocate() }
|
||||||
|
.accessibilityIdentifier("btnProjectRelocateCancel")
|
||||||
|
Button("Choose Folder…") { project.chooseRelocateFolder() }
|
||||||
|
.accessibilityIdentifier("btnProjectRelocate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 420)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("projectRelocateSheet")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ struct RootView: View {
|
|||||||
/// Observed directly: nested ObservableObjects are not tracked
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
/// through the parent's `objectWillChange`.
|
/// through the parent's `objectWillChange`.
|
||||||
@ObservedObject private var model: WizardViewModel
|
@ObservedObject private var model: WizardViewModel
|
||||||
|
@ObservedObject private var project: ProjectSession
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var showingAbout = false
|
@State private var showingAbout = false
|
||||||
@State private var showingAllHelp = false
|
@State private var showingAllHelp = false
|
||||||
@@ -16,6 +17,7 @@ struct RootView: View {
|
|||||||
init(workflow: TargetWorkflowViewModel) {
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -87,6 +89,37 @@ struct RootView: View {
|
|||||||
profileGamURL: workflow.wizard.gamutProfileURL,
|
profileGamURL: workflow.wizard.gamutProfileURL,
|
||||||
showingAllHelp: $showingAllHelp)
|
showingAllHelp: $showingAllHelp)
|
||||||
}
|
}
|
||||||
|
// Project file chrome (issue #149): window title, New confirm
|
||||||
|
// alert, dirty alert, relocate sheet. Panels never appear from
|
||||||
|
// a View — UITestHooks inject fixture paths instead.
|
||||||
|
.onReceive(project.$windowTitle) { title in
|
||||||
|
for window in NSApp.windows where !(window is NSPanel) {
|
||||||
|
window.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Start a new project?", isPresented: $project.showingNewAlert) {
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
.accessibilityIdentifier("btnProjectNewCancel")
|
||||||
|
Button("Start") { project.confirmNew() }
|
||||||
|
.accessibilityIdentifier("btnProjectNewConfirm")
|
||||||
|
} message: {
|
||||||
|
Text("The working folder and targets on disk are not deleted.")
|
||||||
|
.accessibilityIdentifier("projectNewAlert")
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Save the current project first?",
|
||||||
|
isPresented: $project.showingDirtyAlert
|
||||||
|
) {
|
||||||
|
Button("Save") { project.resolveDirty(save: true) }
|
||||||
|
.accessibilityIdentifier("btnProjectDirtySave")
|
||||||
|
Button("Don't Save") { project.resolveDirty(save: false) }
|
||||||
|
.accessibilityIdentifier("btnProjectDirtyDiscard")
|
||||||
|
Button("Cancel", role: .cancel) { project.cancelDirty() }
|
||||||
|
.accessibilityIdentifier("btnProjectDirtyCancel")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $project.showingRelocateSheet) {
|
||||||
|
ProjectRelocateSheet(project: project)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct SidebarView: View {
|
|||||||
@ObservedObject private var media: MediaLibraryViewModel
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
@ObservedObject private var printSession: PrintSessionViewModel
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
@ObservedObject private var measurement: MeasurementWorkflowViewModel
|
@ObservedObject private var measurement: MeasurementWorkflowViewModel
|
||||||
|
@ObservedObject private var project: ProjectSession
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
var onOpenAbout: () -> Void
|
var onOpenAbout: () -> Void
|
||||||
@Binding var showingAllHelp: Bool
|
@Binding var showingAllHelp: Bool
|
||||||
@@ -28,6 +29,7 @@ struct SidebarView: View {
|
|||||||
self._media = ObservedObject(wrappedValue: workflow.media)
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
|
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
|
||||||
|
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||||
self.onOpenSettings = onOpenSettings
|
self.onOpenSettings = onOpenSettings
|
||||||
self.onOpenAbout = onOpenAbout
|
self.onOpenAbout = onOpenAbout
|
||||||
self._showingAllHelp = showingAllHelp
|
self._showingAllHelp = showingAllHelp
|
||||||
@@ -205,6 +207,12 @@ struct SidebarView: View {
|
|||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Project chip (issue #149) — a compact footer in the
|
||||||
|
// spacer's bottom, under the stepper. The 270 pt column
|
||||||
|
// cannot take four more large buttons (R10).
|
||||||
|
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
|
||||||
|
.padding(8)
|
||||||
}
|
}
|
||||||
.frame(width: Theme.Metrics.sidebarWidth)
|
.frame(width: Theme.Metrics.sidebarWidth)
|
||||||
.background(Theme.panel)
|
.background(Theme.panel)
|
||||||
|
|||||||
@@ -119,8 +119,11 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
@Published var print: PrintSessionViewModel!
|
@Published var print: PrintSessionViewModel!
|
||||||
/// Media recipe library — needs a complete `self`.
|
/// Media recipe library — needs a complete `self`.
|
||||||
@Published var media: MediaLibraryViewModel!
|
@Published var media: MediaLibraryViewModel!
|
||||||
/// Spot-read console, created last — needs `wizard` / `measurement`.
|
/// Spot-read console — needs `wizard` / `measurement`.
|
||||||
@Published var spotRead: SpotReadViewModel!
|
@Published var spotRead: SpotReadViewModel!
|
||||||
|
/// Project file session (issue #149), created last — needs `media`
|
||||||
|
/// for recipe apply and `spotRead` for the live-child check.
|
||||||
|
@Published var project: ProjectSession!
|
||||||
|
|
||||||
init(environment: AppEnvironment = .live()) {
|
init(environment: AppEnvironment = .live()) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -148,6 +151,10 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
workflow: self,
|
workflow: self,
|
||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
|
self.project = ProjectSession(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
reloadPresets()
|
reloadPresets()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #149 — `.icceryproj` schema: snake_case keys, strict
|
||||||
|
/// `schema_version == 1`, and save-time refusal for empty/illegal
|
||||||
|
/// basename and empty/unsafe cwd (#59/#60/R11).
|
||||||
|
final class ICCeryProjectTests: XCTestCase {
|
||||||
|
|
||||||
|
private func tempDir() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-proj-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeProject(
|
||||||
|
basename: String = "run-1",
|
||||||
|
cwd: String = "/tmp"
|
||||||
|
) -> ICCeryProject {
|
||||||
|
ICCeryProject(
|
||||||
|
name: "Run One",
|
||||||
|
basename: basename,
|
||||||
|
cwd: cwd,
|
||||||
|
profileBasename: "run-1",
|
||||||
|
printerID: "Mock_Queue",
|
||||||
|
printerDisplayName: "Mock Queue",
|
||||||
|
mediaRecipeID: "recipe-1",
|
||||||
|
presetID: "preset-std-rgb",
|
||||||
|
calibrationURL: "/tmp/r.cal",
|
||||||
|
lastVerification: VerificationSnapshot(
|
||||||
|
date: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
avgDE00: 0.8, maxDE00: 2.1,
|
||||||
|
status: "excellent", profileFilename: "run-1.icc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Round trip / keys
|
||||||
|
|
||||||
|
func testSnakeCaseRoundTrip() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("p.icceryproj")
|
||||||
|
|
||||||
|
let project = makeProject()
|
||||||
|
try project.save(to: url)
|
||||||
|
let loaded = try ICCeryProject.load(from: url)
|
||||||
|
|
||||||
|
XCTAssertEqual(loaded.schemaVersion, 1)
|
||||||
|
XCTAssertEqual(loaded.basename, "run-1")
|
||||||
|
XCTAssertEqual(loaded.cwd, "/tmp")
|
||||||
|
XCTAssertEqual(loaded.profileBasename, "run-1")
|
||||||
|
XCTAssertEqual(loaded.printerID, "Mock_Queue")
|
||||||
|
XCTAssertEqual(loaded.mediaRecipeID, "recipe-1")
|
||||||
|
XCTAssertEqual(loaded.presetID, "preset-std-rgb")
|
||||||
|
XCTAssertEqual(loaded.calibrationURL, "/tmp/r.cal")
|
||||||
|
XCTAssertEqual(loaded.lastVerification?.avgDE00, 0.8)
|
||||||
|
XCTAssertEqual(loaded.lastVerification?.maxDE00, 2.1)
|
||||||
|
XCTAssertEqual(loaded.lastVerification?.status, "excellent")
|
||||||
|
XCTAssertEqual(loaded.lastVerification?.profileFilename, "run-1.icc")
|
||||||
|
|
||||||
|
let raw = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
for key in [
|
||||||
|
"\"schema_version\"", "\"profile_basename\"",
|
||||||
|
"\"printer_id\"", "\"printer_display_name\"",
|
||||||
|
"\"media_recipe_id\"", "\"preset_id\"",
|
||||||
|
"\"calibration_url\"", "\"last_verification\"",
|
||||||
|
"\"avg_de00\"", "\"max_de00\"", "\"profile_filename\"",
|
||||||
|
] {
|
||||||
|
XCTAssertTrue(raw.contains(key), "missing key \(key)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionalFieldsDecodeWhenAbsent() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("min.icceryproj")
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "Minimal",
|
||||||
|
"basename": "abc",
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"updated": "2026-09-13T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try json.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let loaded = try ICCeryProject.load(from: url)
|
||||||
|
XCTAssertEqual(loaded.basename, "abc")
|
||||||
|
XCTAssertNil(loaded.mediaRecipeID)
|
||||||
|
XCTAssertNil(loaded.presetID)
|
||||||
|
XCTAssertNil(loaded.lastVerification)
|
||||||
|
XCTAssertNil(loaded.calibrationURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schema gate
|
||||||
|
|
||||||
|
func testSchemaVersion2Throws() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("v2.icceryproj")
|
||||||
|
try """
|
||||||
|
{"schema_version": 2, "name": "x", "basename": "abc",
|
||||||
|
"cwd": "/tmp", "future_field": [1, 2]}
|
||||||
|
""".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in
|
||||||
|
guard case ICCeryProject.ValidationError.unsupportedSchema(2)
|
||||||
|
= error else {
|
||||||
|
return XCTFail("expected unsupportedSchema, got \(error)")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(
|
||||||
|
error.localizedDescription,
|
||||||
|
"This project file is not schema 1.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingSchemaVersionThrows() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("noversion.icceryproj")
|
||||||
|
try "{\"basename\": \"abc\", \"cwd\": \"/tmp\"}"
|
||||||
|
.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
XCTAssertThrowsError(try ICCeryProject.load(from: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
func testEmptyBasenameRefusesSave() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("p.icceryproj")
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try makeProject(basename: "").save(to: url)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? ICCeryProject.ValidationError, .emptyBasename)
|
||||||
|
}
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIllegalBasenameRefusesSave() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
for bad in ["a/b", "a\\b", "..", "x..y"] {
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try makeProject(basename: bad).save(
|
||||||
|
to: dir.appendingPathComponent("p.icceryproj"))
|
||||||
|
) { error in
|
||||||
|
guard case ICCeryProject.ValidationError.invalidBasename
|
||||||
|
= error else {
|
||||||
|
return XCTFail("expected invalidBasename, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyAndRelativeCwdRefuseSave() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("p.icceryproj")
|
||||||
|
XCTAssertThrowsError(try makeProject(cwd: "").save(to: url)) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? ICCeryProject.ValidationError, .emptyCwd)
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try makeProject(cwd: "relative/dir").save(to: url)
|
||||||
|
) { error in
|
||||||
|
guard case ICCeryProject.ValidationError.unsafeCwd = error else {
|
||||||
|
return XCTFail("expected unsafeCwd, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIllegalBasenameRefusesOpen() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("bad.icceryproj")
|
||||||
|
try """
|
||||||
|
{"schema_version": 1, "basename": "a/b", "cwd": "/tmp"}
|
||||||
|
""".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in
|
||||||
|
guard case ICCeryProject.ValidationError.invalidBasename
|
||||||
|
= error else {
|
||||||
|
return XCTFail("expected invalidBasename, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyNameFallsBackToBasename() throws {
|
||||||
|
let project = try ICCeryProject(
|
||||||
|
name: "", basename: "stem", cwd: "/tmp").validated()
|
||||||
|
XCTAssertEqual(project.name, "stem")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingFileThrowsOnOpen() {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("gone-\(UUID().uuidString).icceryproj")
|
||||||
|
XCTAssertThrowsError(try ICCeryProject.load(from: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #149 — `ProjectSession` open/save/new/close against an
|
||||||
|
/// isolated `TestAppEnvironment`. Disk artefacts always win over the
|
||||||
|
/// project JSON (R18); a `CAL_` basename is never persisted (R11).
|
||||||
|
@MainActor
|
||||||
|
final class ProjectSessionTests: XCTestCase {
|
||||||
|
|
||||||
|
private var env: TestAppEnvironment!
|
||||||
|
private var workflow: TargetWorkflowViewModel!
|
||||||
|
private var project: ProjectSession!
|
||||||
|
private var cwd: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
env = try TestAppEnvironment.make()
|
||||||
|
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||||
|
project = workflow.project
|
||||||
|
cwd = env.root.appendingPathComponent("proj-cwd")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cwd, withIntermediateDirectories: true)
|
||||||
|
// Recents are pushed asynchronously; drain before asserting.
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
env?.cleanup()
|
||||||
|
env = nil
|
||||||
|
workflow = nil
|
||||||
|
project = nil
|
||||||
|
cwd = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets pending `Task`s in the view models run.
|
||||||
|
private func flush() async {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func artefact(_ ext: String, stem: String = "job") throws {
|
||||||
|
try "x".write(
|
||||||
|
to: cwd.appendingPathComponent("\(stem).\(ext)"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeProjectFile(
|
||||||
|
_ name: String = "job.icceryproj",
|
||||||
|
basename: String = "job",
|
||||||
|
cwdOverride: String? = nil,
|
||||||
|
lastVerification: Bool = false,
|
||||||
|
mediaRecipeID: String? = nil,
|
||||||
|
presetID: String? = nil
|
||||||
|
) throws -> URL {
|
||||||
|
let snapshot: VerificationSnapshot? = lastVerification
|
||||||
|
? VerificationSnapshot(
|
||||||
|
date: Date(), avgDE00: 0.7, maxDE00: 1.9,
|
||||||
|
status: "excellent", profileFilename: "\(basename).icc")
|
||||||
|
: nil
|
||||||
|
let p = ICCeryProject(
|
||||||
|
name: "Fixture Project",
|
||||||
|
basename: basename,
|
||||||
|
cwd: cwdOverride ?? cwd.path,
|
||||||
|
mediaRecipeID: mediaRecipeID,
|
||||||
|
presetID: presetID,
|
||||||
|
lastVerification: snapshot)
|
||||||
|
let url = env.root.appendingPathComponent(name)
|
||||||
|
try p.save(to: url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Open
|
||||||
|
|
||||||
|
func testOpenAppliesBasenameCwdAndBinds() async throws {
|
||||||
|
try artefact("ti1")
|
||||||
|
try artefact("ti2")
|
||||||
|
try artefact("ti3")
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||||
|
XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path)
|
||||||
|
XCTAssertEqual(workflow.targetBasename, "job")
|
||||||
|
XCTAssertEqual(workflow.targetDirectory?.path, cwd.path)
|
||||||
|
XCTAssertEqual(project.projectURL, url)
|
||||||
|
XCTAssertEqual(project.project?.name, "Fixture Project")
|
||||||
|
XCTAssertEqual(project.windowTitle, "ICCery — Fixture Project")
|
||||||
|
XCTAssertFalse(project.isDirty)
|
||||||
|
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenDiskWinsOverJsonStage() async throws {
|
||||||
|
// JSON claims a finished profile; disk only has .ti2 (R18).
|
||||||
|
try artefact("ti2")
|
||||||
|
let url = try writeProjectFile(lastVerification: true)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||||
|
XCTAssertTrue(project.diskBehindNotes)
|
||||||
|
let notice = workflow.wizard.notice
|
||||||
|
XCTAssertEqual(notice?.kind, .info)
|
||||||
|
XCTAssertTrue(
|
||||||
|
notice?.text.contains("artefacts on disk stop at .ti2") == true,
|
||||||
|
"got: \(notice?.text ?? "nil")")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenWithTi3ButNoIccLocksStage5Only() async throws {
|
||||||
|
try artefact("ti3")
|
||||||
|
let url = try writeProjectFile(lastVerification: true)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||||
|
XCTAssertTrue(project.diskBehindNotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenSchema2LeavesLiveStateUntouched() async throws {
|
||||||
|
workflow.wizard.setTarget(basename: "live", workingDirectory: cwd)
|
||||||
|
let url = env.root.appendingPathComponent("v2.icceryproj")
|
||||||
|
try """
|
||||||
|
{"schema_version": 2, "basename": "other", "cwd": "\(cwd.path)"}
|
||||||
|
""".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "live")
|
||||||
|
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("not schema 1") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenMissingCwdPresentsRelocateSheet() async throws {
|
||||||
|
let gone = env.root.appendingPathComponent("no-such-dir").path
|
||||||
|
let url = try writeProjectFile(cwdOverride: gone)
|
||||||
|
workflow.wizard.setTarget(basename: "live", workingDirectory: cwd)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertTrue(project.showingRelocateSheet)
|
||||||
|
XCTAssertNotNil(project.pendingRelocate)
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "live")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRelocateCancelAbortsOpen() async throws {
|
||||||
|
let gone = env.root.appendingPathComponent("no-such-dir").path
|
||||||
|
let url = try writeProjectFile(cwdOverride: gone)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
project.cancelRelocate()
|
||||||
|
|
||||||
|
XCTAssertFalse(project.showingRelocateSheet)
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenUnknownMediaRecipeStillOpens() async throws {
|
||||||
|
let url = try writeProjectFile(mediaRecipeID: "recipe-absent")
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
XCTAssertEqual(project.projectURL, url)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||||
|
// The unknown recipe was ignored, not fatal.
|
||||||
|
XCTAssertEqual(workflow.media.selectedRecipeID, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenAppliesPresetWhenNoRecipe() async throws {
|
||||||
|
let url = try writeProjectFile(presetID: "preset-std-rgb")
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save
|
||||||
|
|
||||||
|
func testSaveRefusesEmptyBasename() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.basename = ""
|
||||||
|
|
||||||
|
XCTAssertFalse(project.canSave)
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
XCTAssertFalse(saved)
|
||||||
|
// The file keeps its original basename.
|
||||||
|
let reloaded = try ICCeryProject.load(from: url)
|
||||||
|
XCTAssertEqual(reloaded.basename, "job")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveRefusesEmptyCwd() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.workingDirectory = nil
|
||||||
|
|
||||||
|
XCTAssertFalse(project.canSave)
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
XCTAssertFalse(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalBasenameRefusedWithoutPersistedOriginal() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
// Bare CAL_ stem — no persisted original to trust (R11).
|
||||||
|
workflow.wizard.basename = "CAL_job"
|
||||||
|
workflow.wizard.calibrationOriginalBasename = ""
|
||||||
|
|
||||||
|
XCTAssertTrue(project.canSave) // enabled so the banner shows
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
|
||||||
|
XCTAssertFalse(saved)
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains(
|
||||||
|
"Finish or exit calibration") == true)
|
||||||
|
let raw = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
XCTAssertFalse(raw.contains("CAL_"))
|
||||||
|
XCTAssertEqual(try ICCeryProject.load(from: url).basename, "job")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalBasenameSavesPersistedOriginal() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.basename = "CAL_job"
|
||||||
|
workflow.wizard.calibrationOriginalBasename = "job"
|
||||||
|
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let reloaded = try ICCeryProject.load(from: url)
|
||||||
|
XCTAssertEqual(reloaded.basename, "job")
|
||||||
|
XCTAssertFalse(rawContainsCal(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rawContainsCal(_ url: URL) -> Bool {
|
||||||
|
guard let raw = try? String(contentsOf: url, encoding: .utf8) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return raw.contains("CAL_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveCapturesLiveFields() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||||
|
]
|
||||||
|
workflow.print.selectedPrinter = "Mock_Queue"
|
||||||
|
workflow.selectedPresetID = "preset-std-rgb"
|
||||||
|
workflow.profile.calibrationFile = "/tmp/live.cal"
|
||||||
|
|
||||||
|
XCTAssertTrue(project.isDirty)
|
||||||
|
await flush() // title recompute is deferred one main turn
|
||||||
|
XCTAssertTrue(project.windowTitle.hasSuffix("•"))
|
||||||
|
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let reloaded = try ICCeryProject.load(from: url)
|
||||||
|
XCTAssertEqual(reloaded.printerID, "Mock_Queue")
|
||||||
|
XCTAssertEqual(
|
||||||
|
reloaded.printerDisplayName, "Mock Queue Display")
|
||||||
|
XCTAssertEqual(reloaded.presetID, "preset-std-rgb")
|
||||||
|
XCTAssertEqual(reloaded.calibrationURL, "/tmp/live.cal")
|
||||||
|
XCTAssertFalse(project.isDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - New / Close
|
||||||
|
|
||||||
|
func testNewClearsBasenameKeepsArtefacts() async throws {
|
||||||
|
try artefact("ti3")
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
|
||||||
|
project.requestNew()
|
||||||
|
XCTAssertTrue(project.showingNewAlert)
|
||||||
|
project.confirmNew()
|
||||||
|
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "")
|
||||||
|
XCTAssertEqual(workflow.targetBasename, "")
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(project.windowTitle, "ICCery")
|
||||||
|
XCTAssertEqual(workflow.media.selectedRecipeID, "none")
|
||||||
|
XCTAssertEqual(workflow.wizard.sessionMode, .profile)
|
||||||
|
// The artefact is untouched; the stepper just can't see it.
|
||||||
|
XCTAssertTrue(
|
||||||
|
FileManager.default.fileExists(
|
||||||
|
atPath: cwd.appendingPathComponent("job.ti3").path))
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewWhileChildLiveBannersInstead() async throws {
|
||||||
|
workflow.measurement.isChartreadRunning = true
|
||||||
|
project.requestNew()
|
||||||
|
XCTAssertFalse(project.showingNewAlert)
|
||||||
|
XCTAssertNotNil(workflow.wizard.notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDirtyNewShowsDirtyAlert() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.basename = "changed"
|
||||||
|
|
||||||
|
project.requestNew()
|
||||||
|
XCTAssertFalse(project.showingNewAlert)
|
||||||
|
XCTAssertTrue(project.showingDirtyAlert)
|
||||||
|
|
||||||
|
// Don't Save → New proceeds.
|
||||||
|
project.resolveDirty(save: false)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "")
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCloseKeepsLiveSession() async throws {
|
||||||
|
try artefact("ti1")
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
project.requestClose()
|
||||||
|
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||||
|
XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path)
|
||||||
|
XCTAssertEqual(project.windowTitle, "ICCery")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDirtyCloseSaveThenCloses() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.basename = "renamed"
|
||||||
|
|
||||||
|
project.requestClose()
|
||||||
|
XCTAssertTrue(project.showingDirtyAlert)
|
||||||
|
|
||||||
|
project.resolveDirty(save: true)
|
||||||
|
try await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(try ICCeryProject.load(from: url).basename,
|
||||||
|
"renamed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report / recents
|
||||||
|
|
||||||
|
func testSaveReportWritesMarkdown() async throws {
|
||||||
|
try artefact("ti1")
|
||||||
|
try artefact("ti3")
|
||||||
|
let url = try writeProjectFile(lastVerification: true)
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
project.saveReport()
|
||||||
|
try await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
|
let report = cwd.appendingPathComponent("job-report.md")
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: report.path))
|
||||||
|
let text = try String(contentsOf: report, encoding: .utf8)
|
||||||
|
XCTAssertTrue(text.contains("job.ti1 | exists"))
|
||||||
|
XCTAssertTrue(text.contains("job.ti2 | missing"))
|
||||||
|
XCTAssertTrue(text.contains("job.ti3 | exists"))
|
||||||
|
XCTAssertTrue(text.contains("avg ΔE₀₀ 0.70"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("job-report.md") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenPushesRecent() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
try await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
|
let recents = project.recents
|
||||||
|
XCTAssertEqual(recents.first?.path, url.path)
|
||||||
|
XCTAssertEqual(recents.first?.name, "Fixture Project")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenRecentMissingFileDropped() async throws {
|
||||||
|
let store = env.environment.recentProjectsStore
|
||||||
|
let ghost = env.root.appendingPathComponent("ghost.icceryproj")
|
||||||
|
try await store.add(url: ghost, name: "Ghost")
|
||||||
|
|
||||||
|
project.openRecent(
|
||||||
|
RecentProjectEntry(name: "Ghost", path: ghost.path))
|
||||||
|
try await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("gone") == true)
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertTrue(loaded.isEmpty)
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #149 — `recent_projects.json`: cap 20, newest first, dedupe
|
||||||
|
/// by path, missing files pruned on submenu build, Clear Menu wipes the
|
||||||
|
/// recents file only, corrupt file kept (R12).
|
||||||
|
final class RecentProjectsStoreTests: XCTestCase {
|
||||||
|
|
||||||
|
private var root: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-recents-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
if let root { try? FileManager.default.removeItem(at: root) }
|
||||||
|
root = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storeURL: URL {
|
||||||
|
root.appendingPathComponent("recent_projects.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func projectFile(_ name: String) throws -> URL {
|
||||||
|
let url = root.appendingPathComponent("\(name).icceryproj")
|
||||||
|
try "{}".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapTwentyNewestFirst() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
for i in 0..<25 {
|
||||||
|
let url = try projectFile("p\(i)")
|
||||||
|
try await store.add(url: url, name: "P\(i)")
|
||||||
|
}
|
||||||
|
let all = try await store.load()
|
||||||
|
XCTAssertEqual(all.count, 20)
|
||||||
|
XCTAssertEqual(all.first?.name, "P24")
|
||||||
|
XCTAssertEqual(all.last?.name, "P5")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAddDeduplicatesByPath() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let url = try projectFile("dup")
|
||||||
|
try await store.add(url: url, name: "First")
|
||||||
|
try await store.add(url: try projectFile("other"), name: "Other")
|
||||||
|
try await store.add(url: url, name: "Second")
|
||||||
|
|
||||||
|
let all = try await store.load()
|
||||||
|
XCTAssertEqual(all.count, 2)
|
||||||
|
XCTAssertEqual(all.first?.name, "Second")
|
||||||
|
XCTAssertEqual(
|
||||||
|
all.filter { $0.path == url.path }.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPruneMissingDropsGoneFiles() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let live = try projectFile("live")
|
||||||
|
try await store.add(url: live, name: "Live")
|
||||||
|
try await store.add(
|
||||||
|
url: root.appendingPathComponent("gone.icceryproj"),
|
||||||
|
name: "Gone")
|
||||||
|
|
||||||
|
let pruned = try await store.pruneMissing()
|
||||||
|
XCTAssertEqual(pruned.count, 1)
|
||||||
|
XCTAssertEqual(pruned.first?.name, "Live")
|
||||||
|
|
||||||
|
// The rewrite persisted the drop.
|
||||||
|
let reloaded = try await RecentProjectsStore(url: storeURL).load()
|
||||||
|
XCTAssertEqual(reloaded.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoveByPath() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let url = try projectFile("a")
|
||||||
|
try await store.add(url: url, name: "A")
|
||||||
|
let removed = try await store.remove(path: url.path)
|
||||||
|
XCTAssertTrue(removed)
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertTrue(loaded.isEmpty)
|
||||||
|
let second = try await store.remove(path: url.path)
|
||||||
|
XCTAssertFalse(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearWipesRecentsFileOnly() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let projectURL = try projectFile("keep")
|
||||||
|
try await store.add(url: projectURL, name: "Keep")
|
||||||
|
|
||||||
|
try await store.clear()
|
||||||
|
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertTrue(loaded.isEmpty)
|
||||||
|
// The `.icceryproj` itself survives Clear Menu.
|
||||||
|
XCTAssertTrue(
|
||||||
|
FileManager.default.fileExists(atPath: projectURL.path))
|
||||||
|
let raw = try String(contentsOf: storeURL, encoding: .utf8)
|
||||||
|
XCTAssertTrue(raw.contains("["))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorruptFileThrowsAndKeepsBytes() async throws {
|
||||||
|
try "not json".write(
|
||||||
|
to: storeURL, atomically: true, encoding: .utf8)
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
|
||||||
|
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||||
|
try await store.load()
|
||||||
|
}
|
||||||
|
XCTAssertEqual(
|
||||||
|
try String(contentsOf: storeURL, encoding: .utf8), "not json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEntryStoresBookmarkAndPath() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let url = try projectFile("b")
|
||||||
|
try await store.add(url: url, name: "B")
|
||||||
|
let entry = try await store.load().first
|
||||||
|
XCTAssertEqual(entry?.path, url.path)
|
||||||
|
XCTAssertNotNil(entry?.bookmark)
|
||||||
|
XCTAssertFalse(entry?.bookmarkHash.isEmpty ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBookmarkHashStableAcrossStores() async throws {
|
||||||
|
let a = RecentProjectEntry(name: "x", path: "/tmp/p.icceryproj")
|
||||||
|
let b = RecentProjectEntry(name: "y", path: "/tmp/p.icceryproj")
|
||||||
|
XCTAssertEqual(a.bookmarkHash, b.bookmarkHash)
|
||||||
|
let c = RecentProjectEntry(name: "z", path: "/tmp/q.icceryproj")
|
||||||
|
XCTAssertNotEqual(a.bookmarkHash, c.bookmarkHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ struct TestAppEnvironment {
|
|||||||
var mediaLibraryURL: URL {
|
var mediaLibraryURL: URL {
|
||||||
root.appendingPathComponent("media_library.json")
|
root.appendingPathComponent("media_library.json")
|
||||||
}
|
}
|
||||||
|
var recentProjectsURL: URL {
|
||||||
|
root.appendingPathComponent("recent_projects.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||||
/// Call `cleanup()` when finished.
|
/// Call `cleanup()` when finished.
|
||||||
@@ -65,6 +68,9 @@ struct TestAppEnvironment {
|
|||||||
),
|
),
|
||||||
mediaStore: MediaLibraryStore(
|
mediaStore: MediaLibraryStore(
|
||||||
url: root.appendingPathComponent("media_library.json")
|
url: root.appendingPathComponent("media_library.json")
|
||||||
|
),
|
||||||
|
recentProjectsStore: RecentProjectsStore(
|
||||||
|
url: root.appendingPathComponent("recent_projects.json")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return TestAppEnvironment(root: root, environment: environment)
|
return TestAppEnvironment(root: root, environment: environment)
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 10 UI tests — issue #149 project file. Panels are never
|
||||||
|
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
|
||||||
|
/// inject fixture paths through `UITestHooks`. Menu commands are driven
|
||||||
|
/// by their keyboard shortcuts (⌘N) or the sidebar chip so the tests do
|
||||||
|
/// not depend on menu AX exposure (R19). All queries by identifier.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone10ProjectUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui10p-\(UUID().uuidString)")
|
||||||
|
workDir = testRoot.appendingPathComponent("WorkDir")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: testRoot.appendingPathComponent("AppData"),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
workDir = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp() {
|
||||||
|
app.launch()
|
||||||
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noticeText(timeout: TimeInterval = 10) -> String {
|
||||||
|
let el = waitFor("noticeText", timeout: timeout)
|
||||||
|
return (el.value as? String) ?? el.label
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a `.icceryproj` fixture under `testRoot` and points the
|
||||||
|
/// open-picker hook at it.
|
||||||
|
private func stageProjectFixture(
|
||||||
|
basename: String = "ui149job",
|
||||||
|
lastVerification: Bool = false
|
||||||
|
) throws -> URL {
|
||||||
|
let verification = lastVerification ? """
|
||||||
|
"last_verification": {
|
||||||
|
"date": "2026-09-12T00:00:00Z",
|
||||||
|
"avg_de00": 0.7,
|
||||||
|
"max_de00": 1.9,
|
||||||
|
"status": "excellent",
|
||||||
|
"profile_filename": "\(basename).icc"
|
||||||
|
},
|
||||||
|
""" : ""
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "UI Fixture Project",
|
||||||
|
"notes": "",
|
||||||
|
"basename": "\(basename)",
|
||||||
|
"cwd": "\(workDir.path)",
|
||||||
|
"printer_id": null,
|
||||||
|
"media_recipe_id": null,
|
||||||
|
"preset_id": null,
|
||||||
|
"calibration_url": null,
|
||||||
|
\(verification)
|
||||||
|
"updated": "2026-09-13T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let url = testRoot.appendingPathComponent("fixture.icceryproj")
|
||||||
|
try json.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PROJECT_OPEN"] = url.path
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func artefact(_ ext: String, stem: String = "ui149job") throws {
|
||||||
|
try "x".write(
|
||||||
|
to: workDir.appendingPathComponent("\(stem).\(ext)"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
func testNewProjectClearsBasenameDoesNotDeleteFixtureTi3() throws {
|
||||||
|
try artefact("ti3")
|
||||||
|
_ = try stageProjectFixture()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
// Open the fixture via the chip — never a real panel.
|
||||||
|
waitFor("btnProjectOpen").click()
|
||||||
|
_ = waitFor("projectChipPath")
|
||||||
|
let basenameField = app.textFields["targetBasename"]
|
||||||
|
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertEqual(basenameField.value as? String, "ui149job")
|
||||||
|
|
||||||
|
// ⌘N fires the File-menu New command even when the menu is not
|
||||||
|
// in the AX tree. Mock CUPS may have enumerated a queue that the
|
||||||
|
// fixture does not record, making the session dirty — in that
|
||||||
|
// case the dirty alert gates New first.
|
||||||
|
app.typeKey("n", modifierFlags: .command)
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
var confirmed = false
|
||||||
|
while Date() < deadline {
|
||||||
|
// Dirty sessions show the dirty alert first; discarding it
|
||||||
|
// runs the New reset directly (no second confirm).
|
||||||
|
if element("btnProjectDirtyDiscard").exists {
|
||||||
|
element("btnProjectDirtyDiscard").click()
|
||||||
|
confirmed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if element("btnProjectNewConfirm").exists {
|
||||||
|
element("btnProjectNewConfirm").click()
|
||||||
|
confirmed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(confirmed, "expected the New or dirty alert")
|
||||||
|
|
||||||
|
// Basename cleared (legal "no target" state — not a
|
||||||
|
// placeholder), fixture `.ti3` untouched on disk.
|
||||||
|
let cleared = basenameField.value as? String ?? ""
|
||||||
|
XCTAssertEqual(cleared, "")
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
|
atPath: workDir.appendingPathComponent("ui149job.ti3").path))
|
||||||
|
XCTAssertTrue(element("projectChip").exists)
|
||||||
|
XCTAssertEqual(
|
||||||
|
element("projectChipName").value as? String, "No project")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenProjectDiskWinsOverJsonStage() throws {
|
||||||
|
// JSON claims a finished profile; disk stops at .ti2 (R18).
|
||||||
|
try artefact("ti2")
|
||||||
|
_ = try stageProjectFixture(lastVerification: true)
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
waitFor("btnProjectOpen").click()
|
||||||
|
|
||||||
|
let notice = noticeText()
|
||||||
|
XCTAssertTrue(
|
||||||
|
notice.contains("artefacts on disk stop at .ti2"),
|
||||||
|
"got: \(notice)")
|
||||||
|
XCTAssertTrue(element("projectChipStale").exists)
|
||||||
|
XCTAssertEqual(
|
||||||
|
element("projectChipPath").value as? String, workDir.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveDisabledWithoutBasename() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
_ = waitFor("projectChip")
|
||||||
|
XCTAssertEqual(element("projectChipName").value as? String, "No project")
|
||||||
|
// Chip Save is hidden while unbound; Save As lives in the menu.
|
||||||
|
XCTAssertFalse(element("btnProjectSave").exists)
|
||||||
|
|
||||||
|
// When the File menu is in the AX tree, Save must be disabled.
|
||||||
|
let fileMenu = app.menuBarItems["File"]
|
||||||
|
if fileMenu.waitForExistence(timeout: 3) {
|
||||||
|
fileMenu.click()
|
||||||
|
let save = app.menuItems["menuProjectSave"].firstMatch
|
||||||
|
if save.waitForExistence(timeout: 3) {
|
||||||
|
XCTAssertFalse(save.isEnabled)
|
||||||
|
}
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalBasenameRefused() throws {
|
||||||
|
// A fixture whose stem is CAL_-prefixed binds a live CAL_
|
||||||
|
// basename; the persisted original is empty, so Save must
|
||||||
|
// refuse and never write CAL_ back (R11).
|
||||||
|
let url = try stageProjectFixture(basename: "CAL_ui149")
|
||||||
|
let before = try Data(contentsOf: url)
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
waitFor("btnProjectOpen").click()
|
||||||
|
let save = waitFor("btnProjectSave")
|
||||||
|
XCTAssertTrue(save.isEnabled)
|
||||||
|
save.click()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
noticeText().contains("Finish or exit calibration"),
|
||||||
|
"got: \(noticeText())")
|
||||||
|
XCTAssertEqual(try Data(contentsOf: url), before)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,3 +46,9 @@ Never spawn with `cwd: ""`. Initialize from `get_default_working_dir` (Documents
|
|||||||
## No `"test_target"` fallbacks (#60)
|
## No `"test_target"` fallbacks (#60)
|
||||||
|
|
||||||
A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact.
|
A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact.
|
||||||
|
|
||||||
|
## Project file (#149)
|
||||||
|
|
||||||
|
A `.icceryproj` is a JSON **index** (`schema_version` 1) over `basename` + `cwd` + optional `media_recipe_id`/`preset_id`/`calibration_url`/`printer_*` + a `last_verification` ΔE₀₀ snapshot. It is never a second source of truth: opening one writes `WizardState` and re-runs the same artefact probe window focus uses — if JSON claims Stage 5 but disk stops at `.ti2`, the stepper follows the disk and the sidebar chip shows `projectChipStale` with an info banner.
|
||||||
|
|
||||||
|
`wizard_state.json` remains the crash-resume file; there is no auto-save. Recents live in `appDataDir/recent_projects.json` (cap 20, bookmark + path, corrupt → `[]` + keep bytes). `Save Report…` writes `{basename}-report.md` in cwd atomically. A live `CAL_` basename is never persisted — Save resolves the persisted `calibrationOriginalBasename` or refuses with a banner.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user