Compare commits

..
Author SHA1 Message Date
gronod 9dfa79ebd0 test(m4): wait for Start Read to be enabled before clicking
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Runs 35251 and 35293 left Stage 3 idle because XCTest clicked a
disabled Start Read. driveOnePass already waits for isEnabled;
the handheld average test now uses it.
2026-09-14 15:16:25 +00:00
gronod 1b91b0a94e Merge pull request 'fix(ui): media Apply missing-printer notice visible in manage sheet (#170)' (#171) from fix/170-media-library-missing-printer-notice into develop
macOS CI / build-and-test (push) Failing after 42m49s
macOS CI / package (push) Skipped
fix(ui): media Apply missing-printer notice visible in manage sheet (#170)

Fixes #170
2026-09-14 14:37:05 +01:00
gronod 890e7281eb fix(ui): show media Apply failure inside the manage sheet (#170)
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 2m31s
Failed Apply kept the sheet open while the window banner sat
behind it, so Monterey XCTest never saw noticeText. Mirror the
failure string in the dialog (manageMediaNotice) and point the
UI test at that identifier.

Fixes #170
2026-09-14 13:36:59 +00:00
4 changed files with 36 additions and 42 deletions
+9
View File
@@ -220,6 +220,15 @@ struct ManageMediaDialog: View {
.accessibilityIdentifier("mediaLibraryList") .accessibilityIdentifier("mediaLibraryList")
.frame(minHeight: 260) .frame(minHeight: 260)
if let notice = media.manageApplyNotice {
Text(notice)
.font(.callout)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("manageMediaNotice")
.accessibilityValue(notice)
}
HStack { HStack {
Button("Apply selected") { Button("Apply selected") {
if let id = selection, if let id = selection,
+21 -24
View File
@@ -41,6 +41,9 @@ final class MediaLibraryViewModel: ObservableObject {
@Published var saveMediaApplyCal = false @Published var saveMediaApplyCal = false
/// Inline caption inside the capture sheet (no a11y id roster complete). /// Inline caption inside the capture sheet (no a11y id roster complete).
@Published var saveMediaError: String? @Published var saveMediaError: String?
/// Last failed Apply while Manage is open. The window banner sits
/// behind the sheet on Monterey, so the dialog shows this too (#170).
@Published var manageApplyNotice: String?
/// Pure flow flag the manage sheet's "Capture current" asks the /// Pure flow flag the manage sheet's "Capture current" asks the
/// sheet's `onDismiss` to open the capture sheet, avoiding a /// sheet's `onDismiss` to open the capture sheet, avoiding a
@@ -120,24 +123,21 @@ final class MediaLibraryViewModel: ObservableObject {
/// with warning; the refusal is permanent so re-clicking can't help). /// with warning; the refusal is permanent so re-clicking can't help).
@discardableResult @discardableResult
func apply(_ recipe: MediaRecipe) async -> Bool { func apply(_ recipe: MediaRecipe) async -> Bool {
manageApplyNotice = nil
guard let r = try? recipe.validated() else { guard let r = try? recipe.validated() else {
workflow.wizard.showNotice( return failApply("Media recipe is invalid — not applied.", kind: .error)
"Media recipe is invalid — not applied.", kind: .error)
return false
} }
guard let preset = environment.presetStore.all() guard let preset = environment.presetStore.all()
.first(where: { $0.id == r.presetID }) .first(where: { $0.id == r.presetID })
else { else {
workflow.wizard.showNotice( return failApply(
"Preset \(r.presetID) no longer exists — recipe not applied.", "Preset \(r.presetID) no longer exists — recipe not applied.",
kind: .error) kind: .error)
return false
} }
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else { guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
workflow.wizard.showNotice( return failApply(
"Recipe colour space does not match its preset — not applied.", "Recipe colour space does not match its preset — not applied.",
kind: .error) kind: .error)
return false
} }
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields. // Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
@@ -145,8 +145,6 @@ final class MediaLibraryViewModel: ObservableObject {
// Literal per issue: displayName, not the queue id. // Literal per issue: displayName, not the queue id.
workflow.wizard.printerName = r.printerDisplayName workflow.wizard.printerName = r.printerDisplayName
var succeeded = true
// Queue: enumerate fresh via the session's serialized path // Queue: enumerate fresh via the session's serialized path
// listPrinters uses fixed process ids, so an overlapping // listPrinters uses fixed process ids, so an overlapping
// enumeration would throw duplicateID. An empty result is a // enumeration would throw duplicateID. An empty result is a
@@ -156,16 +154,14 @@ final class MediaLibraryViewModel: ObservableObject {
workflow.print.selectedPrinter = r.printerID workflow.print.selectedPrinter = r.printerID
await workflow.print.reloadSelectedCapabilities() await workflow.print.reloadSelectedCapabilities()
} else { } else {
workflow.wizard.showNotice( return failApply(
"Printer \(r.printerDisplayName) is not installed.", "Printer \(r.printerDisplayName) is not installed.",
kind: .warning) kind: .warning)
succeeded = false
} }
} else { } else {
workflow.wizard.showNotice( return failApply(
"Could not enumerate printers — queue left unchanged.", "Could not enumerate printers — queue left unchanged.",
kind: .warning) kind: .warning)
succeeded = false
} }
// Calibration the recipe is authoritative and runs after // Calibration the recipe is authoritative and runs after
@@ -195,10 +191,8 @@ final class MediaLibraryViewModel: ObservableObject {
guard FileManager.default.fileExists(atPath: calPath) else { guard FileManager.default.fileExists(atPath: calPath) else {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath workflow.profile.calibrationFile = calPath
workflow.wizard.showNotice( return failApply(
"Calibration file is missing: \(calPath)", kind: .error) "Calibration file is missing: \(calPath)", kind: .error)
refreshStaleness()
return false
} }
do { do {
let staleDays = environment.settingsStore.load().calibrationStaleDays let staleDays = environment.settingsStore.load().calibrationStaleDays
@@ -215,23 +209,26 @@ final class MediaLibraryViewModel: ObservableObject {
} }
} catch { } catch {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.wizard.showNotice( return failApply(
"Could not load calibration: \(error.localizedDescription)", "Could not load calibration: \(error.localizedDescription)",
kind: .error) kind: .error)
refreshStaleness()
return false
} }
} else { } else {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath workflow.profile.calibrationFile = calPath
} }
if succeeded { selectedRecipeID = r.id
selectedRecipeID = r.id workflow.wizard.showNotice("Applied \(r.name)")
workflow.wizard.showNotice("Applied \(r.name)")
}
refreshStaleness() refreshStaleness()
return succeeded return true
}
private func failApply(_ text: String, kind: Notice.Kind) -> Bool {
manageApplyNotice = text
workflow.wizard.showNotice(text, kind: kind)
refreshStaleness()
return false
} }
// MARK: - Capture // MARK: - Capture
@@ -148,7 +148,9 @@ final class Milestone10MediaLibraryUITests: XCTestCase {
XCTAssertTrue(apply.waitForExistence(timeout: 10)) XCTAssertTrue(apply.waitForExistence(timeout: 10))
apply.click() apply.click()
let notice = waitFor("noticeText") // The window banner (`noticeText`) sits behind this sheet on
// Monterey (#170). Assert the in-sheet copy instead.
let notice = waitFor("manageMediaNotice", timeout: 15)
let text = (notice.value as? String) ?? notice.label let text = (notice.value as? String) ?? notice.label
XCTAssertTrue( XCTAssertTrue(
text.contains("is not installed"), text.contains("is not installed"),
+3 -17
View File
@@ -98,23 +98,9 @@ final class Milestone4UITests: XCTestCase {
app.buttons["btnDetectInstruments"].click() app.buttons["btnDetectInstruments"].click()
_ = waitFor("chartreadInstrumentSelect", timeout: 20) _ = waitFor("chartreadInstrumentSelect", timeout: 20)
// Keep Auto (port 1) and start the session. // Wait until Start is enabled before clicking. Existence-only
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5)) // clicks are no-ops on the disabled control (runs 35251, 35293).
app.buttons["btnStartRead"].click() driveOnePass(startButton: "btnStartRead")
// Calibrate.
let calibrate = element("btnCalibrate")
if !calibrate.waitForExistence(timeout: 25) {
let error = element("chartreadLastError").label
let value = element("chartreadLastError").value as? String ?? "<nil>"
XCTFail("No calibrate button. lastError.label='\(error)' value='\(value)'")
}
app.buttons["btnCalibrate"].click()
// Trigger each strip until all are read Done & Save appears.
driveStripsUntilDone()
XCTAssertTrue(element("btnDoneRead").exists)
app.buttons["btnDoneRead"].firstMatch.click()
// Averaging panel appears with one pass snapshot. // Averaging panel appears with one pass snapshot.
_ = waitFor("chartreadAveragingPanel", timeout: 20) _ = waitFor("chartreadAveragingPanel", timeout: 20)