Unify wizard process orchestration: logged-run helper, single Notice channel, ProcessLogView #80

Closed
opened 2026-09-10 18:23:12 +01:00 by gronod · 1 comment
Owner

Summary

Every stage view model reimplements “set running flag, clear log, nested Task { @MainActor } log append, wizard.showNotice on success/fail.” Stage-local banners (printNotice + printNoticeIsError, finishNotice + finishNoticeIsError, lastError) duplicate Notice / NoticeBanner.

Extract one orchestration helper and one notice type. Do not change wizard gating or artefact rules.

Spec refs

  • docs/21-ui-reference.md (#wizardNotification, #printNotification, stage log containers)
  • docs/06-wizard-and-artefacts.md (notices are not gates)
  • Existing types: Sources/ICCery/NoticeBanner.swift, WizardViewModel.showNotice

Scope

In

  1. Shared helper used by @MainActor @Observable view models, e.g. Sources/ICCery/ProcessRunSupport.swift:
@MainActor
func appendLog(_ lines: [String], to log: inout [String])

@MainActor
func runLogged<T>(
    setRunning: (Bool) -> Void,
    log: inout [String],
    work: (@escaping @Sendable ([String]) -> Void) async throws -> T
) async throws -> T

The log callback must hop to the main actor once. Today generateTarget / createLayout / createProfile / generateTarget (cal) / computeCurves / finishAndAverage nest Task { @MainActor } inside an already-main task. Kill the inner hop or replace it with MainActor.assumeIsolated only when the callback is known main-isolated; prefer a single documented hop.

Apply in:

  • TargetWorkflowViewModel.generateTarget / createLayout
  • CalibrationViewModel.generateTarget / computeCurves
  • ProfileWorkflowViewModel.createProfile / verifyProfile (and the inner runIccgamut log append)
  • MeasurementWorkflowViewModel.finishAndAverage and chartread log batches
  1. Notice unification:
  • Keep WizardViewModel.notice + NoticeBanner as the session-wide banner.
  • Replace printNotice/printNoticeIsError and finishNotice/finishNoticeIsError with Notice? (printNotice: Notice?, finishNotice: Notice?).
  • Drive Stage 2 print-panel chrome and Stage 3 finish chrome off Notice.kind instead of a parallel bool.
  • lastError on measurement / profile / calibration: either become Notice? or write through wizard.showNotice(..., kind: .error) and drop the unused field. Do not keep both a stage lastError Text and a global banner that say the same thing unless a UI test asserts both ids — if a test asserts #stage4 error text, keep a bound Notice? on that VM, not a raw String.
  1. ProcessLogView in Sources/ICCery/ProcessLogView.swift:
  • DisclosureGroup “Process log”
  • monospaced selectable Text(lines.joined(separator: "\n"))
  • minHeight / maxHeight parameters
  • accessibilityIdentifier for container + log body
  • Adopt in Stage1View.logSection and Stage2View.logSection. Stage 3/4 logs should use it if they are structurally the same.
  1. Dedup importMeasurementDataset — the catch let error as CGATSParseError and generic catch currently emit the same "Import failed: \(error.localizedDescription)". One catch.

Out

  • Splitting TargetWorkflowViewModel into a print session object (sibling ticket). After this ticket, print state still lives on the target VM but uses Notice.
  • Changing auto-hide timings for the global banner (stay at 6 s default). Print/finish notices that are currently sticky remain sticky (autoHideAfter: nil) unless a UI test requires otherwise.

Full solution

  • Accessibility ids stay: targenLog, targenLogContainer, printtargLog, printtargLogContainer, printNotificationIcon, printNotificationText, and any Stage 3 finish notice ids. The view may wrap NoticeBanner or a thin sibling; ids must not vanish.
  • Cancelled print panel remains kind: .info, never .error (issue 17 invariant).
  • Weak-self: helper should take a weak owner or be a free function so Calibration/Target stop mixing Task { @MainActor in (no weak) with Task { @MainActor [weak self] in.

Rewrite invariants

  • #84 / process isolation unchanged — helper only hops coalesced batches.
  • Navigation (wizard.go, refreshGating) stays in the caller, not the helper.
  • Do not invent placeholder basenames while clearing logs (#60).

Dependencies

Blocks-on: none.
Unblocks: print-session split ticket (needs a single notice type before that state moves).

Test

  • Unit: if the helper is testable without AppKit, put it in ICCeryCore or a tiny ICCerySupport file with a Tests/ICCeryCoreTests or app-target test. Otherwise cover via UI tests.
  • Update Tests/ICCeryUITests/Milestone2UITests.swift (targen/printtarg log containers still exist; generate/layout still show success/error banners).
  • Update Milestone3UITests.swift: print cancel → info notice, not error; print failure still sets error kind.
  • Update Milestone4UITests.swift: finish/average success and “promoted first pass” paths still surface a notice.
  • Update Milestone5UITests.swift / Milestone6CalibrationUITests.swift: profile/cal failure still visible.
  • If lastError views go away, retarget assertions at NoticeBanner or the stage Notice? id.
  • Add a unit test that importMeasurementDataset maps parser vs I/O failures through one path (mock file dialog is already UITestHooks).

Acceptance criteria

  • No nested Task { @MainActor } solely to append log lines.
  • printNoticeIsError / finishNoticeIsError deleted.
  • ProcessLogView used by Stage 1 and Stage 2.
  • Accessibility identifiers listed above still resolve in UI tests.
  • Print-panel cancel is info, not error.
  • Milestone 2–6 UI tests green.
## Summary Every stage view model reimplements “set running flag, clear log, nested `Task { @MainActor }` log append, `wizard.showNotice` on success/fail.” Stage-local banners (`printNotice` + `printNoticeIsError`, `finishNotice` + `finishNoticeIsError`, `lastError`) duplicate `Notice` / `NoticeBanner`. Extract one orchestration helper and one notice type. Do not change wizard gating or artefact rules. ## Spec refs - `docs/21-ui-reference.md` (`#wizardNotification`, `#printNotification`, stage log containers) - `docs/06-wizard-and-artefacts.md` (notices are not gates) - Existing types: `Sources/ICCery/NoticeBanner.swift`, `WizardViewModel.showNotice` ## Scope **In** 1. Shared helper used by `@MainActor` `@Observable` view models, e.g. `Sources/ICCery/ProcessRunSupport.swift`: ```swift @MainActor func appendLog(_ lines: [String], to log: inout [String]) @MainActor func runLogged<T>( setRunning: (Bool) -> Void, log: inout [String], work: (@escaping @Sendable ([String]) -> Void) async throws -> T ) async throws -> T ``` The log callback must hop to the main actor **once**. Today `generateTarget` / `createLayout` / `createProfile` / `generateTarget` (cal) / `computeCurves` / `finishAndAverage` nest `Task { @MainActor }` inside an already-main task. Kill the inner hop or replace it with `MainActor.assumeIsolated` only when the callback is known main-isolated; prefer a single documented hop. Apply in: - `TargetWorkflowViewModel.generateTarget` / `createLayout` - `CalibrationViewModel.generateTarget` / `computeCurves` - `ProfileWorkflowViewModel.createProfile` / `verifyProfile` (and the inner `runIccgamut` log append) - `MeasurementWorkflowViewModel.finishAndAverage` and chartread log batches 2. Notice unification: - Keep `WizardViewModel.notice` + `NoticeBanner` as the session-wide banner. - Replace `printNotice`/`printNoticeIsError` and `finishNotice`/`finishNoticeIsError` with `Notice?` (`printNotice: Notice?`, `finishNotice: Notice?`). - Drive Stage 2 print-panel chrome and Stage 3 finish chrome off `Notice.kind` instead of a parallel bool. - `lastError` on measurement / profile / calibration: either become `Notice?` or write through `wizard.showNotice(..., kind: .error)` **and** drop the unused field. Do not keep both a stage `lastError` Text and a global banner that say the same thing unless a UI test asserts both ids — if a test asserts `#stage4` error text, keep a bound `Notice?` on that VM, not a raw `String`. 3. `ProcessLogView` in `Sources/ICCery/ProcessLogView.swift`: - DisclosureGroup “Process log” - monospaced selectable `Text(lines.joined(separator: "\n"))` - `minHeight` / `maxHeight` parameters - `accessibilityIdentifier` for container + log body - Adopt in `Stage1View.logSection` and `Stage2View.logSection`. Stage 3/4 logs should use it if they are structurally the same. 4. Dedup `importMeasurementDataset` — the `catch let error as CGATSParseError` and generic `catch` currently emit the same `"Import failed: \(error.localizedDescription)"`. One catch. **Out** - Splitting `TargetWorkflowViewModel` into a print session object (sibling ticket). After this ticket, print state still lives on the target VM but uses `Notice`. - Changing auto-hide timings for the global banner (stay at 6 s default). Print/finish notices that are currently sticky remain sticky (`autoHideAfter: nil`) unless a UI test requires otherwise. ## Full solution - Accessibility ids stay: `targenLog`, `targenLogContainer`, `printtargLog`, `printtargLogContainer`, `printNotificationIcon`, `printNotificationText`, and any Stage 3 finish notice ids. The view may wrap `NoticeBanner` or a thin sibling; ids must not vanish. - Cancelled print panel remains `kind: .info`, never `.error` (issue 17 invariant). - Weak-self: helper should take a weak owner or be a free function so Calibration/Target stop mixing `Task { @MainActor in` (no weak) with `Task { @MainActor [weak self] in`. ## Rewrite invariants - `#84` / process isolation unchanged — helper only hops coalesced batches. - Navigation (`wizard.go`, `refreshGating`) stays in the caller, not the helper. - Do not invent placeholder basenames while clearing logs (#60). ## Dependencies Blocks-on: none. Unblocks: print-session split ticket (needs a single notice type before that state moves). ## Test - Unit: if the helper is testable without AppKit, put it in `ICCeryCore` or a tiny `ICCerySupport` file with a `Tests/ICCeryCoreTests` or app-target test. Otherwise cover via UI tests. - Update `Tests/ICCeryUITests/Milestone2UITests.swift` (targen/printtarg log containers still exist; generate/layout still show success/error banners). - Update `Milestone3UITests.swift`: print cancel → info notice, not error; print failure still sets error kind. - Update `Milestone4UITests.swift`: finish/average success and “promoted first pass” paths still surface a notice. - Update `Milestone5UITests.swift` / `Milestone6CalibrationUITests.swift`: profile/cal failure still visible. - If `lastError` views go away, retarget assertions at `NoticeBanner` or the stage `Notice?` id. - Add a unit test that `importMeasurementDataset` maps parser vs I/O failures through one path (mock file dialog is already `UITestHooks`). ## Acceptance criteria - [ ] No nested `Task { @MainActor }` solely to append log lines. - [ ] `printNoticeIsError` / `finishNoticeIsError` deleted. - [ ] `ProcessLogView` used by Stage 1 and Stage 2. - [ ] Accessibility identifiers listed above still resolve in UI tests. - [ ] Print-panel cancel is info, not error. - [ ] Milestone 2–6 UI tests green.
gronod added this to the M7 — Deduplicate & consolidate (develop) milestone 2026-09-10 18:23:12 +01:00
gronod self-assigned this 2026-09-10 18:23:12 +01:00
Author
Owner

Implementation originally landed in stacked commit d4261ba via PR #87.
Completion/verification landed in PR #103 at 0d0233e.

Acceptance evidence:

[x] Adopted ProcessRunSupport.runLogged across Calibration, Profile, Measurement, and Target ViewModels.

[x] Removed redundant pre-run state assignments and manual reset calls.

[x] Consolidated Notice usage; deleted printNoticeIsError and finishNoticeIsError flags.

[x] ProcessLogView adopted in Stage3View and Stage4View with deterministic container IDs.

[x] Unit test seam importMeasurementDataset(from:) added and tested for parser/IO errors.

[x] No nested Task { @MainActor } log hops; no direct ViewModel use of logSink.

Verification at milestone/m8-consolidation 891a504ee7:

targeted suites: passed (29 tests)

full universal ICCeryCoreTests: 339 passed, 0 failed

full ICCeryUITests: 29 passed, 0 failed

Closing manually after code and tests are present on the milestone branch.

Implementation originally landed in stacked commit d4261ba via PR #87. Completion/verification landed in PR #103 at 0d0233e. Acceptance evidence: [x] Adopted ProcessRunSupport.runLogged across Calibration, Profile, Measurement, and Target ViewModels. [x] Removed redundant pre-run state assignments and manual reset calls. [x] Consolidated Notice usage; deleted printNoticeIsError and finishNoticeIsError flags. [x] ProcessLogView adopted in Stage3View and Stage4View with deterministic container IDs. [x] Unit test seam importMeasurementDataset(from:) added and tested for parser/IO errors. [x] No nested Task { @MainActor } log hops; no direct ViewModel use of logSink. Verification at milestone/m8-consolidation 891a504ee722037ab15e01c1ae801a4d4cb9415a: targeted suites: passed (29 tests) full universal ICCeryCoreTests: 339 passed, 0 failed full ICCeryUITests: 29 passed, 0 failed Closing manually after code and tests are present on the milestone branch.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gronod/iccery-v2-mac#80