feat(media): media recipe library bound to presets (#146) #151
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
|
||||
/// Persistence for `MediaRecipe` entries (issue #146).
|
||||
///
|
||||
/// `media_library.json` is a sibling of `settings.json`, never a field
|
||||
/// inside it. Writes are atomic via `JSONFileStore` → `AtomicFileWriter`
|
||||
/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is
|
||||
/// never overwritten — the view model turns the throw into an empty
|
||||
/// list plus a persistent warning banner.
|
||||
public actor MediaLibraryStore {
|
||||
|
||||
/// Default cap.
|
||||
public static let defaultCapacity = 200
|
||||
|
||||
/// Path to the JSON store.
|
||||
public let url: URL
|
||||
|
||||
/// In-memory cache, kept in sync with disk.
|
||||
private var recipes: [MediaRecipe] = []
|
||||
|
||||
/// Explicit load flag — an empty file is still "loaded".
|
||||
private var loaded = false
|
||||
|
||||
private let capacity: Int
|
||||
private let fileStore: JSONFileStore<[MediaRecipe]>
|
||||
|
||||
public init(
|
||||
url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"),
|
||||
capacity: Int = defaultCapacity
|
||||
) {
|
||||
self.url = url
|
||||
self.capacity = capacity
|
||||
self.fileStore = JSONFileStore(
|
||||
fileURL: url,
|
||||
corrupt: .throwCorrupt,
|
||||
defaultValue: { [] },
|
||||
dateEncoding: .iso8601,
|
||||
dateDecoding: .iso8601
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads recipes 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 and `loaded` stays false
|
||||
/// so the next call re-reads.
|
||||
public func load() throws -> [MediaRecipe] {
|
||||
guard !loaded else { return recipes }
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
loaded = true
|
||||
return []
|
||||
}
|
||||
recipes = try fileStore.load()
|
||||
loaded = true
|
||||
return recipes
|
||||
}
|
||||
|
||||
/// Returns all cached recipes.
|
||||
public func all() -> [MediaRecipe] {
|
||||
recipes
|
||||
}
|
||||
|
||||
/// Inserts or replaces a recipe matched by `id`, then writes
|
||||
/// atomically. Replacement preserves `created` and bumps `updated`;
|
||||
/// inserts beyond `capacity` throw `.capacityReached` — no silent
|
||||
/// eviction.
|
||||
///
|
||||
/// Loads the existing library first and propagates any load error
|
||||
/// so an unparseable file is never overwritten.
|
||||
@discardableResult
|
||||
public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] {
|
||||
let validated = try recipe.validated()
|
||||
try load()
|
||||
|
||||
var updated = recipes
|
||||
if let index = updated.firstIndex(where: { $0.id == validated.id }) {
|
||||
var existing = validated
|
||||
existing.created = updated[index].created
|
||||
existing.updated = Date()
|
||||
updated[index] = existing
|
||||
} else {
|
||||
guard updated.count < capacity else {
|
||||
throw MediaLibraryError.capacityReached(capacity)
|
||||
}
|
||||
updated.append(validated)
|
||||
}
|
||||
|
||||
try fileStore.save(updated)
|
||||
recipes = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
/// Removes a recipe by id and writes atomically. Returns false when
|
||||
/// no recipe with that id exists.
|
||||
@discardableResult
|
||||
public func delete(id: String) throws -> Bool {
|
||||
try load()
|
||||
let before = recipes.count
|
||||
let updated = recipes.filter { $0.id != id }
|
||||
guard updated.count != before else { return false }
|
||||
try fileStore.save(updated)
|
||||
recipes = updated
|
||||
return true
|
||||
}
|
||||
|
||||
public enum MediaLibraryError: LocalizedError, Equatable {
|
||||
case capacityReached(Int)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .capacityReached(let cap):
|
||||
return "Media library is full (\(cap)). Delete a recipe first."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
/// A media recipe: a named binding of CUPS queue + paper + ink set +
|
||||
/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media
|
||||
/// library).
|
||||
///
|
||||
/// snake_case keys match the v1 JSON schema so `media_library.json`
|
||||
/// stays import/export compatible. Identity + binding fields are
|
||||
/// required; every other field is optional-defaulted. Unknown keys are
|
||||
/// ignored on decode; missing required fields fail the whole array
|
||||
/// decode (corrupt-file policy, never silently dropped).
|
||||
public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
|
||||
|
||||
/// `"recipe-<uuid>"`, never user-typed.
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var notes: String
|
||||
/// CUPS queue id (`Printer.name` — `Printer` has no `id` member).
|
||||
public var printerID: String
|
||||
/// Human label from `Printer.displayName`.
|
||||
public var printerDisplayName: String
|
||||
/// Library metadata only — never written to targen `-P`/`-I` flags.
|
||||
public var paperName: String
|
||||
/// Last captured CUPS `media_type`, read-only.
|
||||
public var driverMediaType: String?
|
||||
/// Free text: `"PK"`, `"MK"`, `"Photo Black"`, …
|
||||
public var inkSet: String
|
||||
/// `"rgb"` | `"cmyk"` — must match the bound preset.
|
||||
public var colourSpace: String
|
||||
/// `ProfilingPreset.id` (built-in or custom).
|
||||
public var presetID: String
|
||||
/// Absolute `.cal` path stored verbatim; `nil` = none.
|
||||
public var calibrationURL: String?
|
||||
public var applyCalibration: Bool
|
||||
public var created: Date
|
||||
public var updated: Date
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
notes: String = "",
|
||||
printerID: String,
|
||||
printerDisplayName: String = "",
|
||||
paperName: String = "",
|
||||
driverMediaType: String? = nil,
|
||||
inkSet: String = "",
|
||||
colourSpace: String,
|
||||
presetID: String,
|
||||
calibrationURL: String? = nil,
|
||||
applyCalibration: Bool = false,
|
||||
created: Date = Date(),
|
||||
updated: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.notes = notes
|
||||
self.printerID = printerID
|
||||
self.printerDisplayName = printerDisplayName
|
||||
self.paperName = paperName
|
||||
self.driverMediaType = driverMediaType
|
||||
self.inkSet = inkSet
|
||||
self.colourSpace = colourSpace
|
||||
self.presetID = presetID
|
||||
self.calibrationURL = calibrationURL
|
||||
self.applyCalibration = applyCalibration
|
||||
self.created = created
|
||||
self.updated = updated
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, notes
|
||||
case printerID = "printer_id"
|
||||
case printerDisplayName = "printer_display_name"
|
||||
case paperName = "paper_name"
|
||||
case driverMediaType = "driver_media_type"
|
||||
case inkSet = "ink_set"
|
||||
case colourSpace = "colour_space"
|
||||
case presetID = "preset_id"
|
||||
case calibrationURL = "calibration_url"
|
||||
case applyCalibration = "apply_calibration"
|
||||
case created, updated
|
||||
}
|
||||
|
||||
/// Strict decode: required identity + binding fields must be
|
||||
/// present; optionals default. Unknown keys are ignored.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
||||
printerID = try c.decode(String.self, forKey: .printerID)
|
||||
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? ""
|
||||
paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? ""
|
||||
driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType)
|
||||
inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? ""
|
||||
colourSpace = try c.decode(String.self, forKey: .colourSpace)
|
||||
presetID = try c.decode(String.self, forKey: .presetID)
|
||||
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
|
||||
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false
|
||||
created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date()
|
||||
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
|
||||
}
|
||||
|
||||
public enum ValidationError: LocalizedError, Equatable {
|
||||
case emptyName
|
||||
case emptyPrinterID
|
||||
case invalidColourSpace(String)
|
||||
case emptyPresetID
|
||||
case invalidCalibrationURL(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyName: return "Media recipe is missing a name."
|
||||
case .emptyPrinterID: return "Media recipe is missing a printer."
|
||||
case .invalidColourSpace(let v):
|
||||
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
|
||||
case .emptyPresetID: return "Media recipe is missing a preset."
|
||||
case .invalidCalibrationURL(let v):
|
||||
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the binding fields. `colourSpace` is normalized to
|
||||
/// lowercase before comparison. `CAL_` cal names are **not**
|
||||
/// rejected — that is an apply-time policy, not schema.
|
||||
@discardableResult
|
||||
public func validated() throws -> MediaRecipe {
|
||||
var r = self
|
||||
r.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
r.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
r.colourSpace = colourSpace.lowercased()
|
||||
guard !r.name.isEmpty else { throw ValidationError.emptyName }
|
||||
guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID }
|
||||
guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else {
|
||||
throw ValidationError.invalidColourSpace(colourSpace)
|
||||
}
|
||||
guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID }
|
||||
if let cal = r.calibrationURL, !cal.isEmpty {
|
||||
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
|
||||
throw ValidationError.invalidCalibrationURL(cal)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ struct AppEnvironment: Sendable {
|
||||
let runner: ArgyllRunner
|
||||
let cupsService: CupsService
|
||||
let historyStore: VerificationHistoryStore
|
||||
let mediaStore: MediaLibraryStore
|
||||
|
||||
static func live(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
@@ -40,7 +41,8 @@ struct AppEnvironment: Sendable {
|
||||
cupsService: CupsService(
|
||||
processManager: .shared,
|
||||
binaryDir: cupsDir),
|
||||
historyStore: VerificationHistoryStore()
|
||||
historyStore: VerificationHistoryStore(),
|
||||
mediaStore: MediaLibraryStore()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// `#saveMediaRecipeDialog` — capture the current printer + paper +
|
||||
/// ink + `.cal` bound to the selected preset (issue #146). Clones
|
||||
/// `SavePresetDialog` chrome; names render via `Text` only (#114).
|
||||
struct SaveMediaRecipeDialog: View {
|
||||
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||
/// Observed directly: nested ObservableObjects are not tracked
|
||||
/// through the parent's `objectWillChange`.
|
||||
@ObservedObject private var media: MediaLibraryViewModel
|
||||
@ObservedObject private var printSession: PrintSessionViewModel
|
||||
|
||||
init(workflow: TargetWorkflowViewModel) {
|
||||
self.workflow = workflow
|
||||
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||
}
|
||||
|
||||
private var printerCaption: String {
|
||||
let queue = printSession.selectedPrinter
|
||||
guard !queue.isEmpty else { return "None" }
|
||||
let display = printSession.printers
|
||||
.first { $0.name == queue }?.displayName ?? queue
|
||||
return "\(display) (\(queue))"
|
||||
}
|
||||
|
||||
private func captureRow(
|
||||
_ label: String, value: String, identifier: String
|
||||
) -> some View {
|
||||
HStack {
|
||||
Text(label).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.accessibilityIdentifier(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
private var saveDisabled: Bool {
|
||||
media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| media.captureColourSpaceMismatch
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||
TextField("Name", text: $media.saveMediaName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaName")
|
||||
TextField("Notes (optional)", text: $media.saveMediaNotes)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaNotes")
|
||||
TextField("Paper", text: $media.saveMediaPaper)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaPaper")
|
||||
TextField("Ink set", text: $media.saveMediaInk)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaInk")
|
||||
|
||||
captureRow("Printer", value: printerCaption,
|
||||
identifier: "saveMediaPrinter")
|
||||
captureRow("Preset",
|
||||
value: workflow.selectedPreset?.name ?? "No preset",
|
||||
identifier: "saveMediaPreset")
|
||||
captureRow("Colour space",
|
||||
value: workflow.colourSpace.rawValue.uppercased(),
|
||||
identifier: "saveMediaColourSpace")
|
||||
captureRow("Calibration",
|
||||
value: workflow.profile.calibrationFile.isEmpty
|
||||
? "None" : workflow.profile.calibrationFile,
|
||||
identifier: "saveMediaCal")
|
||||
Toggle("Apply calibration to profile",
|
||||
isOn: $media.saveMediaApplyCal)
|
||||
.disabled(!media.calApplyable)
|
||||
.accessibilityIdentifier("saveMediaApplyCal")
|
||||
|
||||
if media.captureColourSpaceMismatch {
|
||||
Text("Colour space does not match the selected preset.")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
if let error = media.saveMediaError {
|
||||
Text(error).font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { workflow.showingSaveMedia = false }
|
||||
.accessibilityIdentifier("btnCloseSaveMediaDialog")
|
||||
Button("Save") {
|
||||
Task {
|
||||
if await media.captureFromSession() {
|
||||
workflow.showingSaveMedia = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(saveDisabled)
|
||||
.accessibilityIdentifier("btnConfirmSaveMedia")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.background(Theme.background)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("saveMediaRecipeDialog")
|
||||
}
|
||||
}
|
||||
|
||||
/// `#manageMediaDialog` — list, apply, delete, capture (issue #146).
|
||||
/// `List`, not `Table` — macOS 12 target. Clones `ManagePresetsDialog`.
|
||||
struct ManageMediaDialog: View {
|
||||
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||
/// Observed directly: nested ObservableObjects are not tracked
|
||||
/// through the parent's `objectWillChange`.
|
||||
@ObservedObject private var media: MediaLibraryViewModel
|
||||
@State private var selection: String?
|
||||
@State private var pendingDelete: MediaRecipe?
|
||||
|
||||
init(workflow: TargetWorkflowViewModel) {
|
||||
self.workflow = workflow
|
||||
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||
}
|
||||
|
||||
private func presetCaption(for recipe: MediaRecipe) -> String {
|
||||
workflow.presets.first { $0.id == recipe.presetID }?.name
|
||||
?? "Missing preset"
|
||||
}
|
||||
|
||||
private func calCaption(for recipe: MediaRecipe) -> String {
|
||||
if media.staleReasons[recipe.id]?.contains(.calibration) == true {
|
||||
return "Stale"
|
||||
}
|
||||
if let days = media.calAgeDays[recipe.id] {
|
||||
return "Cal \(days)d"
|
||||
}
|
||||
return "No cal"
|
||||
}
|
||||
|
||||
private func applyAndDismiss(_ recipe: MediaRecipe) {
|
||||
Task {
|
||||
if await media.apply(recipe) {
|
||||
workflow.showingManageMedia = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presetMissing(_ recipe: MediaRecipe) -> Bool {
|
||||
!workflow.presets.contains { $0.id == recipe.presetID }
|
||||
}
|
||||
|
||||
private func calStale(_ recipe: MediaRecipe) -> Bool {
|
||||
media.staleReasons[recipe.id]?.contains(.calibration) == true
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(_ recipe: MediaRecipe) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(recipe.name).foregroundStyle(Theme.text)
|
||||
Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
Text(presetCaption(for: recipe))
|
||||
.font(.caption)
|
||||
.foregroundStyle(presetMissing(recipe) ? .orange : .secondary)
|
||||
Text(calCaption(for: recipe))
|
||||
.font(.caption)
|
||||
.foregroundStyle(calStale(recipe) ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Apply") { applyAndDismiss(recipe) }
|
||||
.accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)")
|
||||
Button("Delete", role: .destructive) {
|
||||
pendingDelete = recipe
|
||||
}
|
||||
.accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("mediaRow-\(recipe.id)")
|
||||
.tag(recipe.id)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
TapGesture(count: 2).onEnded { applyAndDismiss(recipe) }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text)
|
||||
|
||||
List(selection: $selection) {
|
||||
if media.recipes.isEmpty {
|
||||
Text("No media recipes yet. Capture the current printer, paper and preset.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("mediaLibraryEmpty")
|
||||
}
|
||||
ForEach(media.recipes) { recipe in
|
||||
row(recipe)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("mediaLibraryList")
|
||||
.frame(minHeight: 260)
|
||||
|
||||
HStack {
|
||||
Button("Apply selected") {
|
||||
if let id = selection,
|
||||
let recipe = media.recipes.first(where: { $0.id == id }) {
|
||||
applyAndDismiss(recipe)
|
||||
}
|
||||
}
|
||||
.disabled(selection == nil)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.accessibilityIdentifier("btnMediaLibraryApply")
|
||||
Button("Capture current…") {
|
||||
media.captureAfterManageDismiss = true
|
||||
workflow.showingManageMedia = false
|
||||
}
|
||||
.accessibilityIdentifier("btnMediaLibraryCaptureFromManage")
|
||||
Spacer()
|
||||
Button("Close") { workflow.showingManageMedia = false }
|
||||
.accessibilityIdentifier("btnCloseManageMediaDialog")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 640)
|
||||
.background(Theme.background)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("manageMediaDialog")
|
||||
.onAppear {
|
||||
media.reload()
|
||||
media.refreshStaleness()
|
||||
}
|
||||
.alert(
|
||||
"Delete media recipe?",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
presenting: pendingDelete
|
||||
) { recipe in
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
Button("Delete", role: .destructive) {
|
||||
media.delete(recipe)
|
||||
pendingDelete = nil
|
||||
}
|
||||
} message: { recipe in
|
||||
Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
/// Media recipe library: capture / apply / staleness for issue #146.
|
||||
///
|
||||
/// A recipe binds a CUPS queue + paper + ink + `.cal` to a
|
||||
/// `ProfilingPreset`. Applying a recipe goes through the existing
|
||||
/// `applyPreset` (#82) path — there is no second Stage 1 form. Paper
|
||||
/// and ink are library metadata only; they are never written to the
|
||||
/// targen label fields.
|
||||
@MainActor
|
||||
final class MediaLibraryViewModel: ObservableObject {
|
||||
|
||||
/// Why a recipe row is flagged stale.
|
||||
struct StaleReason: OptionSet {
|
||||
let rawValue: Int
|
||||
static let calibration = StaleReason(rawValue: 1 << 0)
|
||||
static let printer = StaleReason(rawValue: 1 << 1)
|
||||
}
|
||||
|
||||
let workflow: TargetWorkflowViewModel
|
||||
let environment: AppEnvironment
|
||||
private let store: MediaLibraryStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@Published var recipes: [MediaRecipe] = []
|
||||
/// Sidebar picker selection; `"none"` = no media recipe. Written
|
||||
/// only on a successful apply so a failed apply snaps back.
|
||||
@Published var selectedRecipeID = "none"
|
||||
/// `recipe.id` → stale reasons for the sidebar badge / manage sheet.
|
||||
@Published var staleReasons: [String: StaleReason] = [:]
|
||||
/// `recipe.id` → whole days since the bound `.cal` was created.
|
||||
@Published var calAgeDays: [String: Int] = [:]
|
||||
|
||||
// Save-sheet state (mirrors savePresetName/savePresetDesc).
|
||||
@Published var saveMediaName = ""
|
||||
@Published var saveMediaNotes = ""
|
||||
@Published var saveMediaPaper = ""
|
||||
@Published var saveMediaInk = ""
|
||||
@Published var saveMediaApplyCal = false
|
||||
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
||||
@Published var saveMediaError: String?
|
||||
|
||||
/// Pure flow flag — the manage sheet's "Capture current…" asks the
|
||||
/// sheet's `onDismiss` to open the capture sheet, avoiding a
|
||||
/// present-while-dismissing race.
|
||||
var captureAfterManageDismiss = false
|
||||
|
||||
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||
self.workflow = workflow
|
||||
self.environment = environment
|
||||
self.store = environment.mediaStore
|
||||
|
||||
reload()
|
||||
refreshStaleness()
|
||||
// The library needs queues enumerated at launch so Capture can
|
||||
// enable and the not-installed badge is computable; Stage 2 only
|
||||
// enumerates when a printtarg manifest exists.
|
||||
if workflow.print.printers.isEmpty {
|
||||
workflow.print.refreshPrinters()
|
||||
}
|
||||
|
||||
NotificationCenter.default
|
||||
.publisher(for: SettingsStore.settingsDidChange)
|
||||
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||
.store(in: &cancellables)
|
||||
workflow.print.$printers
|
||||
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||
.store(in: &cancellables)
|
||||
workflow.print.$selectedPrinter
|
||||
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
deinit { cancellables.removeAll() }
|
||||
|
||||
// MARK: - Load / corrupt
|
||||
|
||||
func reload() {
|
||||
Task { await reloadAsync() }
|
||||
}
|
||||
|
||||
func reloadAsync() async {
|
||||
do {
|
||||
recipes = try await store.load()
|
||||
} catch {
|
||||
// Corrupt-file policy: keep the file, keep the cache,
|
||||
// persistent warning; the picker falls back to "none".
|
||||
workflow.wizard.showNotice(
|
||||
"Media library is unreadable — the existing file was kept.",
|
||||
kind: .warning,
|
||||
autoHideAfter: nil
|
||||
)
|
||||
if recipes.isEmpty { selectedRecipeID = "none" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection / apply
|
||||
|
||||
/// Sidebar `mediaSelect` binding. `"none"` clears the selection
|
||||
/// without resetting any Stage 1/2/4 field — it is not "reset to
|
||||
/// factory".
|
||||
func selectRecipe(_ id: String) {
|
||||
if id == "none" {
|
||||
selectedRecipeID = "none"
|
||||
return
|
||||
}
|
||||
guard let recipe = recipes.first(where: { $0.id == id }) else { return }
|
||||
Task { _ = await apply(recipe) }
|
||||
}
|
||||
|
||||
/// The single apply path — sidebar picker, manage-row Apply, and
|
||||
/// the manage footer all funnel here.
|
||||
///
|
||||
/// Returns `false` when any bound resource is unresolved (missing
|
||||
/// preset, colour-space mismatch, queue absent, missing/unparseable
|
||||
/// `.cal`) so the manage sheet stays open and the picker reverts.
|
||||
/// A `CAL_`-blocked calibration counts as applied (`true` — success
|
||||
/// with warning; the refusal is permanent so re-clicking can't help).
|
||||
@discardableResult
|
||||
func apply(_ recipe: MediaRecipe) async -> Bool {
|
||||
guard let r = try? recipe.validated() else {
|
||||
workflow.wizard.showNotice(
|
||||
"Media recipe is invalid — not applied.", kind: .error)
|
||||
return false
|
||||
}
|
||||
guard let preset = environment.presetStore.all()
|
||||
.first(where: { $0.id == r.presetID })
|
||||
else {
|
||||
workflow.wizard.showNotice(
|
||||
"Preset \(r.presetID) no longer exists — recipe not applied.",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
|
||||
workflow.wizard.showNotice(
|
||||
"Recipe colour space does not match its preset — not applied.",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
|
||||
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
|
||||
workflow.applyPreset(preset)
|
||||
// Literal per issue: displayName, not the queue id.
|
||||
workflow.wizard.printerName = r.printerDisplayName
|
||||
|
||||
var succeeded = true
|
||||
|
||||
// Queue: enumerate fresh via the session's serialized path —
|
||||
// listPrinters uses fixed process ids, so an overlapping
|
||||
// enumeration would throw duplicateID. An empty result is a
|
||||
// valid list.
|
||||
if let queues = await workflow.print.enumeratePrinters() {
|
||||
if queues.contains(where: { $0.name == r.printerID }) {
|
||||
workflow.print.selectedPrinter = r.printerID
|
||||
await workflow.print.reloadSelectedCapabilities()
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
"Printer \(r.printerDisplayName) is not installed.",
|
||||
kind: .warning)
|
||||
succeeded = false
|
||||
}
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
"Could not enumerate printers — queue left unchanged.",
|
||||
kind: .warning)
|
||||
succeeded = false
|
||||
}
|
||||
|
||||
// Calibration — the recipe is authoritative and runs after
|
||||
// applyPreset so the preset's own cal fields don't win.
|
||||
let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let calStem = URL(fileURLWithPath: calPath)
|
||||
.deletingPathExtension().lastPathComponent
|
||||
let blocked = r.applyCalibration && !calPath.isEmpty
|
||||
&& (CalibrationIdentity.isCalibration(calStem)
|
||||
|| CalibrationIdentity.isCalibration(workflow.wizard.basename))
|
||||
|
||||
if blocked {
|
||||
// Literal CAL_ refusal on both names (decision 1): keep the
|
||||
// path for display but never let `printtarg -K` see it.
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
selectedRecipeID = r.id
|
||||
refreshStaleness()
|
||||
workflow.wizard.showNotice(
|
||||
"Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.",
|
||||
kind: .warning,
|
||||
autoHideAfter: nil)
|
||||
return true
|
||||
}
|
||||
|
||||
if r.applyCalibration && !calPath.isEmpty {
|
||||
guard FileManager.default.fileExists(atPath: calPath) else {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
workflow.wizard.showNotice(
|
||||
"Calibration file is missing: \(calPath)", kind: .error)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||
let calStore = CalibrationStore(staleDays: staleDays)
|
||||
try await calStore.load(url: URL(fileURLWithPath: calPath))
|
||||
workflow.profile.calibrationFile = calPath
|
||||
workflow.profile.applyCalibration = true
|
||||
// Age check only — the .cal DESCRIPTOR is free text, not
|
||||
// a queue id, so a name compare false-positives.
|
||||
if await calStore.isStale() {
|
||||
workflow.wizard.showNotice(
|
||||
"Applied \(r.name) — calibration is stale.",
|
||||
kind: .warning)
|
||||
}
|
||||
} catch {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.wizard.showNotice(
|
||||
"Could not load calibration: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
}
|
||||
|
||||
if succeeded {
|
||||
selectedRecipeID = r.id
|
||||
workflow.wizard.showNotice("Applied \(r.name)")
|
||||
}
|
||||
refreshStaleness()
|
||||
return succeeded
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
/// Whether the live `profile.calibrationFile` may be applied:
|
||||
/// non-empty, not a `CAL_` stem, and present on disk.
|
||||
var calApplyable: Bool {
|
||||
let path = workflow.profile.calibrationFile
|
||||
guard !path.isEmpty else { return false }
|
||||
let stem = URL(fileURLWithPath: path)
|
||||
.deletingPathExtension().lastPathComponent
|
||||
guard !CalibrationIdentity.isCalibration(stem) else { return false }
|
||||
return FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
/// A bound preset whose colour space disagrees with the live form —
|
||||
/// the sheet shows the mismatch caption and disables Save.
|
||||
var captureColourSpaceMismatch: Bool {
|
||||
guard let preset = workflow.selectedPreset else { return false }
|
||||
return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue
|
||||
}
|
||||
|
||||
/// Opens the capture sheet, prefilled from the selected recipe else
|
||||
/// the most recently captured one ("last recipe … or empty").
|
||||
func beginCapture() {
|
||||
let source = recipes.first(where: { $0.id == selectedRecipeID })
|
||||
?? recipes.last
|
||||
saveMediaPaper = source?.paperName ?? ""
|
||||
saveMediaInk = source?.inkSet ?? ""
|
||||
saveMediaName = ""
|
||||
saveMediaNotes = ""
|
||||
saveMediaError = nil
|
||||
saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable
|
||||
if workflow.print.printers.isEmpty {
|
||||
workflow.print.refreshPrinters()
|
||||
}
|
||||
workflow.showingSaveMedia = true
|
||||
}
|
||||
|
||||
/// Save button — the sheet closes only on `true`.
|
||||
func captureFromSession() async -> Bool {
|
||||
let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else {
|
||||
saveMediaError = "Name, paper and ink are required."
|
||||
return false
|
||||
}
|
||||
|
||||
let queue = workflow.print.selectedPrinter
|
||||
guard !queue.isEmpty else {
|
||||
saveMediaError = "Select a printer in Stage 2 first."
|
||||
return false
|
||||
}
|
||||
|
||||
// Preset binding: the selected preset when its colour space
|
||||
// matches the live form; otherwise auto-snapshot the live form
|
||||
// as a custom preset (decision 2).
|
||||
let presetID: String
|
||||
if let bound = workflow.selectedPreset {
|
||||
guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else {
|
||||
saveMediaError = "Colour space does not match the selected preset."
|
||||
return false
|
||||
}
|
||||
presetID = bound.id
|
||||
} else {
|
||||
let snapshot = ProfilingPreset(
|
||||
id: "custom-\(UUID().uuidString.lowercased())",
|
||||
name: name,
|
||||
description: "Auto-saved for media recipe",
|
||||
targen: workflow.buildTargenConfig(),
|
||||
printtarg: workflow.buildPrinttargConfig(),
|
||||
colprof: workflow.profile.buildColprofConfig(),
|
||||
calibrationFile: nil,
|
||||
applyCalibration: nil
|
||||
)
|
||||
do {
|
||||
try environment.presetStore.saveCustom(snapshot)
|
||||
workflow.reloadPresets()
|
||||
workflow.selectedPresetID = snapshot.id
|
||||
} catch {
|
||||
saveMediaError = "Could not save preset: \(error.localizedDescription)"
|
||||
return false
|
||||
}
|
||||
presetID = snapshot.id
|
||||
}
|
||||
|
||||
// The cal path is stored verbatim; applyCalibration is forced
|
||||
// off for CAL_/missing paths via calApplyable.
|
||||
let calPath = workflow.profile.calibrationFile
|
||||
let printer = workflow.print.printers.first { $0.name == queue }
|
||||
let now = Date()
|
||||
let recipe = MediaRecipe(
|
||||
id: "recipe-\(UUID().uuidString.lowercased())",
|
||||
name: name,
|
||||
notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
printerID: queue,
|
||||
printerDisplayName: printer?.displayName ?? queue,
|
||||
paperName: paper,
|
||||
driverMediaType: workflow.print.selectedMediaType,
|
||||
inkSet: ink,
|
||||
colourSpace: workflow.colourSpace.rawValue,
|
||||
presetID: presetID,
|
||||
calibrationURL: calPath.isEmpty ? nil : calPath,
|
||||
applyCalibration: saveMediaApplyCal && calApplyable,
|
||||
created: now,
|
||||
updated: now
|
||||
)
|
||||
|
||||
do {
|
||||
let validated = try recipe.validated()
|
||||
try await store.upsert(validated)
|
||||
await reloadAsync()
|
||||
selectedRecipeID = validated.id
|
||||
workflow.wizard.showNotice("Media recipe saved: \(validated.name)")
|
||||
return true
|
||||
} catch let error as MediaLibraryStore.MediaLibraryError {
|
||||
saveMediaError = error.errorDescription
|
||||
return false
|
||||
} catch {
|
||||
saveMediaError = "Could not save: \(error.localizedDescription)"
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func delete(_ recipe: MediaRecipe) {
|
||||
Task {
|
||||
do {
|
||||
try await store.delete(id: recipe.id)
|
||||
await reloadAsync()
|
||||
if selectedRecipeID == recipe.id {
|
||||
selectedRecipeID = "none"
|
||||
}
|
||||
} catch {
|
||||
workflow.wizard.showNotice(
|
||||
"Could not delete: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Staleness
|
||||
|
||||
func refreshStaleness() {
|
||||
Task { await refreshStalenessAsync() }
|
||||
}
|
||||
|
||||
/// Recomputes `staleReasons` + `calAgeDays` for every recipe.
|
||||
///
|
||||
/// `.printer` fires only when `printerID` is absent from a
|
||||
/// **non-empty** enumerated queue list — an un-enumerated list is
|
||||
/// indeterminate, not stale (decision 5). `.calibration` is a pure
|
||||
/// age check (`isStale()` with no `comparedTo:`) — the `.cal`
|
||||
/// DESCRIPTOR is free text, not a queue id.
|
||||
func refreshStalenessAsync() async {
|
||||
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||
let queues = workflow.print.printers
|
||||
let now = Date()
|
||||
let calStore = CalibrationStore(staleDays: staleDays)
|
||||
|
||||
var reasons: [String: StaleReason] = [:]
|
||||
var ages: [String: Int] = [:]
|
||||
for r in recipes {
|
||||
var flags: StaleReason = []
|
||||
if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) {
|
||||
flags.insert(.printer)
|
||||
}
|
||||
if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces),
|
||||
!raw.isEmpty,
|
||||
FileManager.default.fileExists(atPath: raw),
|
||||
(try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil,
|
||||
let created = await calStore.data?.created {
|
||||
ages[r.id] = Calendar.current
|
||||
.dateComponents([.day], from: created, to: now).day ?? 0
|
||||
if await calStore.isStale() {
|
||||
flags.insert(.calibration)
|
||||
}
|
||||
}
|
||||
if !flags.isEmpty { reasons[r.id] = flags }
|
||||
}
|
||||
staleReasons = reasons
|
||||
calAgeDays = ages
|
||||
}
|
||||
|
||||
// MARK: - Manage sheet flow
|
||||
|
||||
/// Called from the manage sheet's `onDismiss`. A deferred capture
|
||||
/// request opens the save sheet only now, after the manage sheet has
|
||||
/// fully dismissed.
|
||||
func manageDismissed() {
|
||||
if captureAfterManageDismiss {
|
||||
captureAfterManageDismiss = false
|
||||
beginCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,24 +24,41 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
private var printerEnumTask: Task<[Printer]?, Never>?
|
||||
|
||||
func refreshPrinters() {
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
Task { @MainActor in _ = await enumeratePrinters() }
|
||||
}
|
||||
|
||||
/// Serialized queue enumeration — `listPrinters` uses fixed process
|
||||
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
|
||||
/// callers coalesce onto the in-flight task (#146).
|
||||
@discardableResult
|
||||
func enumeratePrinters() async -> [Printer]? {
|
||||
if let pending = printerEnumTask { return await pending.value }
|
||||
let task = Task { @MainActor [weak self] () -> [Printer]? in
|
||||
guard let self else { return nil }
|
||||
do {
|
||||
let list = try await cups.listPrinters()
|
||||
printers = list
|
||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
||||
selectedPrinter = list.first { $0.isDefault }?.name
|
||||
let list = try await self.environment.cupsService.listPrinters()
|
||||
self.printers = list
|
||||
if !list.contains(where: { $0.name == self.selectedPrinter }) {
|
||||
self.selectedPrinter = list.first { $0.isDefault }?.name
|
||||
?? list.first?.name ?? ""
|
||||
}
|
||||
await reloadSelectedCapabilities()
|
||||
await self.reloadSelectedCapabilities()
|
||||
return list
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
self.printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Could not list printers: \(error.localizedDescription)"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
printerEnumTask = task
|
||||
let result = await task.value
|
||||
printerEnumTask = nil
|
||||
return result
|
||||
}
|
||||
|
||||
func reloadSelectedCapabilities() async {
|
||||
|
||||
@@ -56,6 +56,17 @@ struct RootView: View {
|
||||
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||
ManagePresetsDialog(workflow: workflow)
|
||||
}
|
||||
// Media library sheets live on RootView, never inside the
|
||||
// 270 pt sidebar column (#146).
|
||||
.sheet(isPresented: $workflow.showingSaveMedia) {
|
||||
SaveMediaRecipeDialog(workflow: workflow)
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $workflow.showingManageMedia,
|
||||
onDismiss: { workflow.media.manageDismissed() }
|
||||
) {
|
||||
ManageMediaDialog(workflow: workflow)
|
||||
}
|
||||
.sheet(isPresented: $showingAbout) {
|
||||
AboutView { showingAbout = false }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ struct SidebarView: View {
|
||||
/// through the parent's `objectWillChange`.
|
||||
@ObservedObject private var model: WizardViewModel
|
||||
@ObservedObject private var profile: ProfileWorkflowViewModel
|
||||
@ObservedObject private var media: MediaLibraryViewModel
|
||||
@ObservedObject private var printSession: PrintSessionViewModel
|
||||
var onOpenSettings: () -> Void
|
||||
var onOpenAbout: () -> Void
|
||||
@Binding var showingAllHelp: Bool
|
||||
@@ -22,6 +24,8 @@ struct SidebarView: View {
|
||||
self.workflow = workflow
|
||||
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
||||
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||
self.onOpenSettings = onOpenSettings
|
||||
self.onOpenAbout = onOpenAbout
|
||||
self._showingAllHelp = showingAllHelp
|
||||
@@ -90,6 +94,56 @@ struct SidebarView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Media library (`#mediaSelect`) — issue #146. Selection
|
||||
// applies the recipe immediately, like presets; names render
|
||||
// via Text only (#114). Never reuses `presetSelect` (#137).
|
||||
Picker("Media", selection: Binding(
|
||||
get: { media.selectedRecipeID },
|
||||
set: { media.selectRecipe($0) }
|
||||
)) {
|
||||
Text("No media recipe").tag("none")
|
||||
ForEach(media.recipes) { recipe in
|
||||
Text(recipe.name).tag(recipe.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.accessibilityIdentifier("mediaSelect")
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.helpOverlay(
|
||||
"Saved printer + paper + ink + .cal bound to a preset.",
|
||||
showing: $showingAllHelp)
|
||||
|
||||
if let reasons = media.staleReasons[media.selectedRecipeID],
|
||||
!reasons.isEmpty {
|
||||
Text(reasons.contains(.printer)
|
||||
? "Printer not installed" : "Calibration stale")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 12)
|
||||
.accessibilityIdentifier("mediaRecipeStale")
|
||||
.helpOverlay(
|
||||
"Re-run Stage 0 or pick a different recipe.",
|
||||
showing: $showingAllHelp)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Capture") { media.beginCapture() }
|
||||
.disabled(printSession.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnMediaLibraryCapture")
|
||||
.helpOverlay(
|
||||
"Select a printer in Stage 2 first",
|
||||
showing: $showingAllHelp)
|
||||
Button("Manage") { workflow.showingManageMedia = true }
|
||||
.accessibilityIdentifier("btnMediaLibraryManage")
|
||||
.helpOverlay(
|
||||
"Apply or delete saved media recipes.",
|
||||
showing: $showingAllHelp)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||
Button(action: { model.enterCalibration() }) {
|
||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||
|
||||
@@ -97,6 +97,11 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
@Published var savePresetName = ""
|
||||
@Published var savePresetDesc = ""
|
||||
|
||||
// MARK: - Media library (issue #146)
|
||||
|
||||
@Published var showingSaveMedia = false
|
||||
@Published var showingManageMedia = false
|
||||
|
||||
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||
/// across stage switches and can observe settings changes.
|
||||
@Published var measurement: MeasurementWorkflowViewModel
|
||||
@@ -107,6 +112,8 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
@Published var calibration: CalibrationViewModel!
|
||||
/// Stage 2 unmanaged print session.
|
||||
@Published var print: PrintSessionViewModel!
|
||||
/// Media recipe library, created last — it needs a complete `self`.
|
||||
@Published var media: MediaLibraryViewModel!
|
||||
|
||||
init(environment: AppEnvironment = .live()) {
|
||||
self.environment = environment
|
||||
@@ -126,6 +133,10 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
profile: self.profile,
|
||||
environment: environment
|
||||
)
|
||||
self.media = MediaLibraryViewModel(
|
||||
workflow: self,
|
||||
environment: environment
|
||||
)
|
||||
reloadPresets()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue #146 — `MediaLibraryStore` persistence contract.
|
||||
final class MediaLibraryStoreTests: XCTestCase {
|
||||
|
||||
private var url: URL!
|
||||
|
||||
override func setUp() {
|
||||
url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("media-lib-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
url = nil
|
||||
}
|
||||
|
||||
private func recipe(id: String, name: String = "R") -> MediaRecipe {
|
||||
MediaRecipe(
|
||||
id: id, name: name, printerID: "q",
|
||||
colourSpace: "rgb", presetID: "preset-std-rgb")
|
||||
}
|
||||
|
||||
func testRoundTrip() async throws {
|
||||
let store = MediaLibraryStore(url: url)
|
||||
let r = recipe(id: "recipe-1", name: "Epson Rag")
|
||||
try await store.upsert(r)
|
||||
let loaded = try await store.load()
|
||||
XCTAssertEqual(loaded, [r])
|
||||
}
|
||||
|
||||
func testCorruptFileThrowsAndPreservesBytes() async throws {
|
||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
let before = try Data(contentsOf: url)
|
||||
|
||||
let store = MediaLibraryStore(url: url)
|
||||
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||
try await store.load()
|
||||
}
|
||||
// upsert must also propagate — a corrupt file is never wiped.
|
||||
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||
try await store.upsert(recipe(id: "x"))
|
||||
}
|
||||
XCTAssertEqual(try Data(contentsOf: url), before)
|
||||
}
|
||||
|
||||
func testDelete() async throws {
|
||||
let store = MediaLibraryStore(url: url)
|
||||
try await store.upsert(recipe(id: "a"))
|
||||
try await store.upsert(recipe(id: "b"))
|
||||
|
||||
let removed = try await store.delete(id: "a")
|
||||
XCTAssertTrue(removed)
|
||||
let remaining = try await store.load().map(\.id)
|
||||
XCTAssertEqual(remaining, ["b"])
|
||||
|
||||
let again = try await store.delete(id: "a")
|
||||
XCTAssertFalse(again)
|
||||
}
|
||||
|
||||
func testCapacityReached() async throws {
|
||||
let store = MediaLibraryStore(url: url, capacity: 2)
|
||||
try await store.upsert(recipe(id: "1"))
|
||||
try await store.upsert(recipe(id: "2"))
|
||||
await assertAsyncThrows(
|
||||
expectedType: MediaLibraryStore.MediaLibraryError.self
|
||||
) {
|
||||
try await store.upsert(recipe(id: "3"))
|
||||
} errorHandler: {
|
||||
XCTAssertEqual($0, .capacityReached(2))
|
||||
}
|
||||
let stored = try await store.load()
|
||||
XCTAssertEqual(stored.count, 2)
|
||||
}
|
||||
|
||||
func testUpsertPreservesCreatedBumpsUpdated() async throws {
|
||||
let store = MediaLibraryStore(url: url)
|
||||
var r = recipe(id: "recipe-1")
|
||||
r.created = Date(timeIntervalSince1970: 1_000_000)
|
||||
r.updated = Date(timeIntervalSince1970: 1_000_000)
|
||||
try await store.upsert(r)
|
||||
|
||||
var edit = r
|
||||
edit.name = "Renamed"
|
||||
edit.updated = Date(timeIntervalSince1970: 2_000_000)
|
||||
try await store.upsert(edit)
|
||||
|
||||
let loaded = try await store.load()
|
||||
XCTAssertEqual(loaded.count, 1)
|
||||
XCTAssertEqual(loaded[0].name, "Renamed")
|
||||
XCTAssertEqual(loaded[0].created, r.created)
|
||||
XCTAssertGreaterThan(loaded[0].updated, r.updated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #146 — `MediaLibraryViewModel` apply / capture / staleness
|
||||
/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir.
|
||||
@MainActor
|
||||
final class MediaLibraryViewModelTests: XCTestCase {
|
||||
|
||||
// CTI3 fixture mirrored from CalibrationStoreTests.
|
||||
private static let sampleCal = """
|
||||
CTI3
|
||||
DESCRIPTOR "Test printer"
|
||||
COLOR_REP "RGB"
|
||||
DEVICE_CLASS "OUTPUT"
|
||||
MAX_TAC "300"
|
||||
NUMBER_OF_FIELDS 5
|
||||
NUMBER_OF_SETS 3
|
||||
BEGIN_DATA_FORMAT
|
||||
SAMPLE_ID INPUT_VALUE R G B
|
||||
END_DATA_FORMAT
|
||||
BEGIN_DATA
|
||||
1 0 0 0 0
|
||||
2 128 64 64 64
|
||||
3 255 255 255 255
|
||||
END_DATA
|
||||
"""
|
||||
|
||||
/// Same fixture plus an old CREATED keyword so the age check fires.
|
||||
private static let staleCal = """
|
||||
CTI3
|
||||
DESCRIPTOR "Test printer"
|
||||
CREATED "2020-01-01T00:00:00Z"
|
||||
COLOR_REP "RGB"
|
||||
DEVICE_CLASS "OUTPUT"
|
||||
NUMBER_OF_FIELDS 5
|
||||
NUMBER_OF_SETS 3
|
||||
BEGIN_DATA_FORMAT
|
||||
SAMPLE_ID INPUT_VALUE R G B
|
||||
END_DATA_FORMAT
|
||||
BEGIN_DATA
|
||||
1 0 0 0 0
|
||||
2 128 64 64 64
|
||||
3 255 255 255 255
|
||||
END_DATA
|
||||
"""
|
||||
|
||||
private var env: TestAppEnvironment!
|
||||
private var workflow: TargetWorkflowViewModel!
|
||||
private var media: MediaLibraryViewModel!
|
||||
|
||||
override func setUp() async throws {
|
||||
env = try TestAppEnvironment.make()
|
||||
try installMockCups()
|
||||
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||
media = workflow.media
|
||||
await media.reloadAsync()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
env?.cleanup()
|
||||
env = nil
|
||||
workflow = nil
|
||||
media = nil
|
||||
}
|
||||
|
||||
/// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the
|
||||
/// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues:
|
||||
/// `Mock_Queue` (default) and `Other_Queue`.
|
||||
private func installMockCups() throws {
|
||||
let bin = env.root.appendingPathComponent("cups-bin")
|
||||
try FileManager.default.createDirectory(
|
||||
at: bin, withIntermediateDirectories: true)
|
||||
|
||||
let lpstat = """
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
-e) printf 'Mock_Queue\\nOther_Queue\\n' ;;
|
||||
-p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;;
|
||||
-d) printf 'system default destination: Mock_Queue\\n' ;;
|
||||
esac
|
||||
exit 0
|
||||
"""
|
||||
let lpoptions = """
|
||||
#!/bin/sh
|
||||
list=0
|
||||
queue=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-l) list=1 ;;
|
||||
-p) ;;
|
||||
*) queue="$arg" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: *A4 Letter\\n'
|
||||
printf 'MediaType/Media Type: *Stationery Glossy\\n'
|
||||
exit 0
|
||||
fi
|
||||
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
||||
exit 0
|
||||
"""
|
||||
for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] {
|
||||
let url = bin.appendingPathComponent(name)
|
||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeCal(
|
||||
_ contents: String = MediaLibraryViewModelTests.sampleCal,
|
||||
named name: String = "recipe.cal"
|
||||
) throws -> String {
|
||||
let url = env.root.appendingPathComponent(name)
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
return url.path
|
||||
}
|
||||
|
||||
private func makeRecipe(
|
||||
id: String = "recipe-t1",
|
||||
printerID: String = "Mock_Queue",
|
||||
colourSpace: String = "rgb",
|
||||
presetID: String = "preset-std-rgb",
|
||||
calibrationURL: String? = nil,
|
||||
applyCalibration: Bool = false
|
||||
) -> MediaRecipe {
|
||||
MediaRecipe(
|
||||
id: id,
|
||||
name: "Test Recipe",
|
||||
printerID: printerID,
|
||||
printerDisplayName: "Mock Queue Display",
|
||||
paperName: "Rag",
|
||||
inkSet: "PK",
|
||||
colourSpace: colourSpace,
|
||||
presetID: presetID,
|
||||
calibrationURL: calibrationURL,
|
||||
applyCalibration: applyCalibration)
|
||||
}
|
||||
|
||||
private func seed(_ recipe: MediaRecipe) async throws {
|
||||
try await env.environment.mediaStore.upsert(recipe)
|
||||
await media.reloadAsync()
|
||||
}
|
||||
|
||||
// MARK: - Apply
|
||||
|
||||
func testApplyHappyPath() async throws {
|
||||
let calPath = try writeCal()
|
||||
let r = makeRecipe(
|
||||
calibrationURL: calPath, applyCalibration: true)
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue")
|
||||
XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display")
|
||||
XCTAssertTrue(workflow.profile.applyCalibration)
|
||||
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
|
||||
XCTAssertEqual(media.selectedRecipeID, r.id)
|
||||
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
|
||||
XCTAssertEqual(
|
||||
workflow.buildPrinttargConfig().calibrationFile, calPath)
|
||||
}
|
||||
|
||||
func testApplyMissingPresetRefuses() async throws {
|
||||
let r = makeRecipe(presetID: "preset-nonexistent")
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertFalse(applied)
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
XCTAssertEqual(workflow.selectedPresetID, "none")
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("no longer exists") == true)
|
||||
}
|
||||
|
||||
func testApplyColourSpaceMismatchRefuses() async throws {
|
||||
let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb")
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertFalse(applied)
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("colour space") == true)
|
||||
}
|
||||
|
||||
func testApplyMissingCalFileFails() async throws {
|
||||
let missing = env.root.appendingPathComponent("gone.cal").path
|
||||
let r = makeRecipe(
|
||||
calibrationURL: missing, applyCalibration: true)
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertFalse(applied)
|
||||
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||
XCTAssertEqual(workflow.profile.calibrationFile, missing)
|
||||
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
}
|
||||
|
||||
func testApplyCalPrefixedCalCannotArmK() async throws {
|
||||
let calPath = try writeCal(named: "CAL_target.cal")
|
||||
let r = makeRecipe(
|
||||
calibrationURL: calPath, applyCalibration: true)
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
// Success with warning — the refusal is permanent, re-clicking
|
||||
// cannot unstick it (decision 6).
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
|
||||
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
|
||||
XCTAssertEqual(media.selectedRecipeID, r.id)
|
||||
XCTAssertEqual(workflow.wizard.notice?.kind, .warning)
|
||||
}
|
||||
|
||||
func testApplyLiveCalBasenameBlocks() async throws {
|
||||
let calPath = try writeCal()
|
||||
let r = makeRecipe(
|
||||
calibrationURL: calPath, applyCalibration: true)
|
||||
try await seed(r)
|
||||
workflow.wizard.basename = "CAL_live"
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
|
||||
}
|
||||
|
||||
func testApplyMissingQueueLeavesQueueUntouched() async throws {
|
||||
workflow.print.selectedPrinter = "Other_Queue"
|
||||
let r = makeRecipe(printerID: "No_Such_Queue")
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertFalse(applied)
|
||||
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue")
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("is not installed") == true)
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
func testCaptureCopiesPrinterAndPreset() async throws {
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||
]
|
||||
workflow.print.selectedPrinter = "Mock_Queue"
|
||||
workflow.selectedPresetID = "preset-std-rgb"
|
||||
media.saveMediaName = "My Recipe"
|
||||
media.saveMediaPaper = "Rag"
|
||||
media.saveMediaInk = "PK"
|
||||
|
||||
let saved = await media.captureFromSession()
|
||||
|
||||
XCTAssertTrue(saved)
|
||||
let stored = await env.environment.mediaStore.all()
|
||||
XCTAssertEqual(stored.count, 1)
|
||||
XCTAssertEqual(stored[0].printerID, "Mock_Queue")
|
||||
XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display")
|
||||
XCTAssertEqual(stored[0].presetID, "preset-std-rgb")
|
||||
XCTAssertEqual(stored[0].colourSpace, "rgb")
|
||||
XCTAssertEqual(media.selectedRecipeID, stored[0].id)
|
||||
}
|
||||
|
||||
func testCaptureNoPresetAutoSnapshots() async throws {
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||
]
|
||||
workflow.print.selectedPrinter = "Mock_Queue"
|
||||
workflow.selectedPresetID = "none"
|
||||
media.saveMediaName = "Snap"
|
||||
media.saveMediaPaper = "Rag"
|
||||
media.saveMediaInk = "MK"
|
||||
|
||||
let saved = await media.captureFromSession()
|
||||
|
||||
XCTAssertTrue(saved)
|
||||
let stored = await env.environment.mediaStore.all()
|
||||
XCTAssertEqual(stored.count, 1)
|
||||
XCTAssertTrue(stored[0].presetID.hasPrefix("custom-"))
|
||||
XCTAssertTrue(
|
||||
env.environment.presetStore.customs()
|
||||
.contains { $0.id == stored[0].presetID })
|
||||
XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID)
|
||||
}
|
||||
|
||||
func testCaptureRequiresPrinter() async {
|
||||
workflow.print.selectedPrinter = ""
|
||||
media.saveMediaName = "n"
|
||||
media.saveMediaPaper = "p"
|
||||
media.saveMediaInk = "i"
|
||||
|
||||
let saved = await media.captureFromSession()
|
||||
|
||||
XCTAssertFalse(saved)
|
||||
XCTAssertNotNil(media.saveMediaError)
|
||||
}
|
||||
|
||||
func testCaptureForcesOffCalToggleForCalFile() async throws {
|
||||
let calPath = try writeCal(named: "CAL_target.cal")
|
||||
workflow.profile.calibrationFile = calPath
|
||||
workflow.profile.applyCalibration = true
|
||||
workflow.print.printers = [Printer(name: "Mock_Queue")]
|
||||
workflow.print.selectedPrinter = "Mock_Queue"
|
||||
workflow.selectedPresetID = "preset-std-rgb"
|
||||
media.saveMediaName = "n"
|
||||
media.saveMediaPaper = "p"
|
||||
media.saveMediaInk = "i"
|
||||
media.saveMediaApplyCal = true // forced off by calApplyable
|
||||
|
||||
XCTAssertFalse(media.calApplyable)
|
||||
let saved = await media.captureFromSession()
|
||||
|
||||
XCTAssertTrue(saved)
|
||||
let stored = await env.environment.mediaStore.all()
|
||||
XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim
|
||||
XCTAssertFalse(stored[0].applyCalibration)
|
||||
}
|
||||
|
||||
// MARK: - Staleness
|
||||
|
||||
func testStaleCalFlagged() async throws {
|
||||
let calPath = try writeCal(
|
||||
MediaLibraryViewModelTests.staleCal, named: "old.cal")
|
||||
let r = makeRecipe(calibrationURL: calPath)
|
||||
try await seed(r)
|
||||
workflow.print.printers = [Printer(name: "Mock_Queue")]
|
||||
|
||||
await media.refreshStalenessAsync()
|
||||
|
||||
XCTAssertTrue(
|
||||
media.staleReasons[r.id]?.contains(.calibration) == true)
|
||||
XCTAssertNotNil(media.calAgeDays[r.id])
|
||||
}
|
||||
|
||||
func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws {
|
||||
let r = makeRecipe(printerID: "No_Such_Queue")
|
||||
try await seed(r)
|
||||
|
||||
// Un-enumerated list is indeterminate → no flag.
|
||||
workflow.print.printers = []
|
||||
await media.refreshStalenessAsync()
|
||||
XCTAssertNil(media.staleReasons[r.id])
|
||||
|
||||
// Absent from a non-empty list → .printer.
|
||||
workflow.print.printers = [Printer(name: "Other_Queue")]
|
||||
await media.refreshStalenessAsync()
|
||||
XCTAssertTrue(
|
||||
media.staleReasons[r.id]?.contains(.printer) == true)
|
||||
|
||||
// Present but unselected → no flag.
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"),
|
||||
]
|
||||
workflow.print.selectedPrinter = "Other_Queue"
|
||||
await media.refreshStalenessAsync()
|
||||
XCTAssertNil(media.staleReasons[r.id])
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func testDeleteResetsSelection() async throws {
|
||||
let r = makeRecipe()
|
||||
try await seed(r)
|
||||
media.selectedRecipeID = r.id
|
||||
|
||||
media.delete(r)
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
let stored = await env.environment.mediaStore.all()
|
||||
XCTAssertTrue(stored.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Corrupt library
|
||||
|
||||
func testCorruptLibraryKeepsFileAndWarns() async throws {
|
||||
// Fresh environment so the store's `loaded` flag is still false.
|
||||
let env2 = try TestAppEnvironment.make()
|
||||
defer { env2.cleanup() }
|
||||
try "garbage".write(
|
||||
to: env2.mediaLibraryURL, atomically: true, encoding: .utf8)
|
||||
let workflow2 = TargetWorkflowViewModel(
|
||||
environment: env2.environment)
|
||||
await workflow2.media.reloadAsync()
|
||||
|
||||
XCTAssertTrue(workflow2.media.recipes.isEmpty)
|
||||
XCTAssertEqual(workflow2.media.selectedRecipeID, "none")
|
||||
XCTAssertEqual(workflow2.wizard.notice?.kind, .warning)
|
||||
XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL),
|
||||
"garbage".data(using: .utf8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue #146 — `MediaRecipe` Codable + validation contract.
|
||||
final class MediaRecipeTests: XCTestCase {
|
||||
|
||||
private func makeRecipe() -> MediaRecipe {
|
||||
MediaRecipe(
|
||||
id: "recipe-abc",
|
||||
name: "Epson Rag",
|
||||
notes: "notes",
|
||||
printerID: "epson_p900",
|
||||
printerDisplayName: "Epson SureColor P900",
|
||||
paperName: "Rag Photographique",
|
||||
driverMediaType: "PhotographicGlossy",
|
||||
inkSet: "PK",
|
||||
colourSpace: "rgb",
|
||||
presetID: "preset-std-rgb",
|
||||
calibrationURL: "/tmp/prof.cal",
|
||||
applyCalibration: true,
|
||||
created: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
updated: Date(timeIntervalSince1970: 1_700_000_100)
|
||||
)
|
||||
}
|
||||
|
||||
func testRoundTripSnakeCase() throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(makeRecipe())
|
||||
let object = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
|
||||
for key in [
|
||||
"printer_id", "printer_display_name", "paper_name",
|
||||
"driver_media_type", "ink_set", "colour_space", "preset_id",
|
||||
"calibration_url", "apply_calibration", "created", "updated",
|
||||
"id", "name", "notes",
|
||||
] {
|
||||
XCTAssertNotNil(object[key], "missing key \(key)")
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let decoded = try decoder.decode(MediaRecipe.self, from: data)
|
||||
XCTAssertEqual(decoded, makeRecipe())
|
||||
}
|
||||
|
||||
func testMissingRequiredKeyThrows() {
|
||||
for key in ["id", "name", "printer_id", "colour_space", "preset_id"] {
|
||||
var dict: [String: Any] = [
|
||||
"id": "r1", "name": "n", "printer_id": "q",
|
||||
"colour_space": "rgb", "preset_id": "p",
|
||||
]
|
||||
dict.removeValue(forKey: key)
|
||||
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||
XCTAssertThrowsError(
|
||||
try JSONDecoder().decode(MediaRecipe.self, from: data),
|
||||
"expected throw without \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
func testUnknownKeysIgnored() throws {
|
||||
let dict: [String: Any] = [
|
||||
"id": "r1", "name": "n", "printer_id": "q",
|
||||
"colour_space": "rgb", "preset_id": "p",
|
||||
"future_field": "ignored",
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: dict)
|
||||
let recipe = try JSONDecoder().decode(MediaRecipe.self, from: data)
|
||||
XCTAssertEqual(recipe.id, "r1")
|
||||
XCTAssertEqual(recipe.notes, "")
|
||||
XCTAssertFalse(recipe.applyCalibration)
|
||||
}
|
||||
|
||||
func testValidatedGoldens() {
|
||||
var r = makeRecipe()
|
||||
|
||||
r.name = " "
|
||||
XCTAssertThrowsError(try r.validated()) {
|
||||
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyName)
|
||||
}
|
||||
|
||||
r = makeRecipe()
|
||||
r.printerID = ""
|
||||
XCTAssertThrowsError(try r.validated()) {
|
||||
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPrinterID)
|
||||
}
|
||||
|
||||
r = makeRecipe()
|
||||
r.presetID = ""
|
||||
XCTAssertThrowsError(try r.validated()) {
|
||||
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPresetID)
|
||||
}
|
||||
|
||||
r = makeRecipe()
|
||||
r.colourSpace = "lab"
|
||||
XCTAssertThrowsError(try r.validated()) {
|
||||
XCTAssertEqual(
|
||||
$0 as? MediaRecipe.ValidationError, .invalidColourSpace("lab"))
|
||||
}
|
||||
|
||||
for bad in ["../evil.cal", "rel/path.cal", "/tmp/a\0b.cal"] {
|
||||
r = makeRecipe()
|
||||
r.calibrationURL = bad
|
||||
XCTAssertThrowsError(try r.validated(), "expected throw for \(bad)") {
|
||||
XCTAssertEqual(
|
||||
$0 as? MediaRecipe.ValidationError,
|
||||
.invalidCalibrationURL(bad))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testColourSpaceNormalisedToLowercase() throws {
|
||||
var r = makeRecipe()
|
||||
r.colourSpace = "RGB"
|
||||
let validated = try r.validated()
|
||||
XCTAssertEqual(validated.colourSpace, "rgb")
|
||||
}
|
||||
|
||||
func testCalPrefixedCalNameIsSchemaValid() throws {
|
||||
var r = makeRecipe()
|
||||
// CAL_ refusal is an apply-time policy, not a schema error.
|
||||
r.calibrationURL = "/tmp/CAL_target.cal"
|
||||
XCTAssertNoThrow(try r.validated())
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ struct TestAppEnvironment {
|
||||
var historyURL: URL {
|
||||
root.appendingPathComponent("verification_history.json")
|
||||
}
|
||||
var mediaLibraryURL: URL {
|
||||
root.appendingPathComponent("media_library.json")
|
||||
}
|
||||
|
||||
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||
/// Call `cleanup()` when finished.
|
||||
@@ -49,6 +52,9 @@ struct TestAppEnvironment {
|
||||
),
|
||||
historyStore: VerificationHistoryStore(
|
||||
url: root.appendingPathComponent("verification_history.json")
|
||||
),
|
||||
mediaStore: MediaLibraryStore(
|
||||
url: root.appendingPathComponent("media_library.json")
|
||||
)
|
||||
)
|
||||
return TestAppEnvironment(root: root, environment: environment)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import XCTest
|
||||
|
||||
/// Milestone 10 UI tests — issue #146 media recipe library. Mock CUPS
|
||||
/// binaries (`ICCERY_CUPS_BIN_DIR` → `Fixtures/bin`) emit
|
||||
/// `Mock_Epson_7450` / `Mock_Canon_Pro`; recipes are seeded by writing
|
||||
/// `<ICCERY_TEST_ROOT>/AppData/media_library.json` before launch —
|
||||
/// `AppPaths` redirects app data under `ICCERY_TEST_ROOT`. All queries
|
||||
/// are by identifier only ("Media" also appears in help overlays).
|
||||
@MainActor
|
||||
final class Milestone10MediaLibraryUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ui10-\(UUID().uuidString)")
|
||||
binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
|
||||
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: appData, withIntermediateDirectories: true)
|
||||
// A recipe bound to a queue that is never enumerated.
|
||||
let fixture = """
|
||||
[
|
||||
{
|
||||
"id": "fixture-missing-queue",
|
||||
"name": "Missing Queue Recipe",
|
||||
"notes": "",
|
||||
"printer_id": "No_Such_Queue",
|
||||
"printer_display_name": "Missing Queue",
|
||||
"paper_name": "Rag",
|
||||
"ink_set": "PK",
|
||||
"colour_space": "rgb",
|
||||
"preset_id": "preset-std-rgb",
|
||||
"calibration_url": null,
|
||||
"apply_calibration": false,
|
||||
"created": "2026-09-12T00:00:00Z",
|
||||
"updated": "2026-09-12T00:00:00Z"
|
||||
}
|
||||
]
|
||||
"""
|
||||
try fixture.write(
|
||||
to: appData.appendingPathComponent("media_library.json"),
|
||||
atomically: true, encoding: .utf8)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 waitForEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||
let el = waitFor(id, timeout: timeout)
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if el.isEnabled { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
func testMediaPickerDoesNotReusePresetSelect() throws {
|
||||
launchApp()
|
||||
|
||||
let preset = app.popUpButtons["presetSelect"]
|
||||
XCTAssertTrue(preset.waitForExistence(timeout: 10))
|
||||
let media = app.popUpButtons["mediaSelect"]
|
||||
XCTAssertTrue(media.waitForExistence(timeout: 10))
|
||||
}
|
||||
|
||||
func testCaptureRequiresNamePaperInk() throws {
|
||||
launchApp()
|
||||
|
||||
// Capture enables once the mock CUPS enumeration selects a queue.
|
||||
let capture = waitForEnabled("btnMediaLibraryCapture")
|
||||
XCTAssertTrue(capture.isEnabled)
|
||||
capture.click()
|
||||
|
||||
_ = waitFor("saveMediaRecipeDialog")
|
||||
let save = element("btnConfirmSaveMedia")
|
||||
XCTAssertTrue(save.exists)
|
||||
XCTAssertFalse(save.isEnabled)
|
||||
|
||||
for (id, text) in [
|
||||
("saveMediaName", "UI Recipe"),
|
||||
("saveMediaPaper", "Rag"),
|
||||
("saveMediaInk", "PK"),
|
||||
] {
|
||||
let field = element(id)
|
||||
field.click()
|
||||
field.typeText(text)
|
||||
}
|
||||
|
||||
XCTAssertTrue(save.isEnabled)
|
||||
}
|
||||
|
||||
func testManageApplyMissingPrinterShowsBanner() throws {
|
||||
launchApp()
|
||||
|
||||
let manage = app.buttons["btnMediaLibraryManage"]
|
||||
XCTAssertTrue(manage.waitForExistence(timeout: 10))
|
||||
manage.click()
|
||||
_ = waitFor("manageMediaDialog")
|
||||
|
||||
let apply = element("btnMediaLibraryApply-fixture-missing-queue")
|
||||
XCTAssertTrue(apply.waitForExistence(timeout: 10))
|
||||
apply.click()
|
||||
|
||||
let notice = waitFor("noticeText")
|
||||
let text = (notice.value as? String) ?? notice.label
|
||||
XCTAssertTrue(
|
||||
text.contains("is not installed"),
|
||||
"expected not-installed notice, got: \(text)")
|
||||
|
||||
// A failed apply keeps the manage sheet open and the sidebar
|
||||
// picker reverts.
|
||||
XCTAssertTrue(element("manageMediaDialog").exists)
|
||||
XCTAssertTrue(element("mediaSelect").exists)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -67,3 +67,25 @@ Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JS
|
||||
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
|
||||
|
||||
UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
|
||||
|
||||
## Media library (`media_library.json`)
|
||||
|
||||
Persisted at `{app_data}/media_library.json` — a sibling of `settings.json`, never a field inside it (issue #146). A `MediaRecipe` binds a CUPS queue + paper + ink set + optional `.cal` to a `ProfilingPreset`. Cap: 200 entries; the 201st is refused with an error, never silently evicted. Corrupt JSON → keep the file, load `[]`, persistent warning banner.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `id` | string | `recipe-<uuid>`, never user-typed |
|
||||
| `name`, `notes` | string | Rendered through `Text` only (#114) |
|
||||
| `printer_id` | string | CUPS queue id (`lpstat -e` name) |
|
||||
| `printer_display_name` | string | Human label; applied to `wizard.printerName` |
|
||||
| `paper_name`, `ink_set` | string | Library metadata only — never written to targen flags |
|
||||
| `driver_media_type` | string? | Last captured CUPS `media_type` (read-only) |
|
||||
| `colour_space` | `"rgb"` \| `"cmyk"` | Must match the bound preset |
|
||||
| `preset_id` | string | `ProfilingPreset.id` (built-in or custom) |
|
||||
| `calibration_url` | string? | Absolute `.cal` path, stored verbatim |
|
||||
| `apply_calibration` | bool | Forced off for `CAL_` stems or missing files |
|
||||
| `created`, `updated` | iso8601 | |
|
||||
|
||||
Apply path: recipe → `applyPreset` (#82 mapping, no second Stage 1 form) → queue re-enumerated (`lpstat -e`) → `printer_id` absent from a non-empty list warns "not installed" and leaves the queue untouched; an empty list is indeterminate and never flags. `CAL_` bound cal **or** a live `CAL_` wizard basename forces `applyCalibration` off — `printtarg -K` can never see a `CAL_` file (literal refusal; escape hatch is rename + re-capture). Staleness: `.printer` = absent from enumerated queues; `.calibration` = bound cal `CREATED + calibration_stale_days < now`.
|
||||
|
||||
Capture with no preset selected auto-snapshots the live form as a `custom-` preset and binds to it. UI: `#mediaSelect` (immediate apply, `#presetSelect`-style), `#btnMediaLibraryCapture` → `#saveMediaRecipeDialog`, `#btnMediaLibraryManage` → `#manageMediaDialog` (`mediaLibraryList`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`), stale badge `#mediaRecipeStale`.
|
||||
|
||||
Reference in New Issue
Block a user