diff --git a/DESIGN.md b/DESIGN.md index 294a008..572a661 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -723,6 +723,21 @@ Include this `:root` block in all generated CSS files: } ``` +### Sync-status Icon Tokens + +Two-icon presence model (2026-05-30). Each is an intent-first alias of an existing +token, so component CSS reads `var(--color-sync-safe)` over a bare green. Colour +language: blue = here, green = safe on NAS, gray = absent, red = problem, amber = held. + +| Token | Resolves to | Hex | Meaning | +|------------------------|------------------------|-----------|------------------------------| +| `--color-sync-local` | `--color-blue` | `#1b75bc` | Present locally (here) | +| `--color-sync-cached` | `--color-row-selected` | `#dceaff` | Present, also on NAS (cache) | +| `--color-sync-safe` | `--color-success` | `#009E73` | Safe on NAS | +| `--color-sync-absent` | `--color-rule` | `#e8ecf2` | Absent (fine) | +| `--color-sync-problem` | `--color-danger` | `#D55E00` | Problem here | +| `--color-sync-held` | `--color-warning` | `#E69F00` | Held here | + --- ## 08 -- Usage Rules & Anti-Patterns diff --git a/docs/superpowers/specs/2026-05-30-two-icon-sync-presence-design.md b/docs/superpowers/specs/2026-05-30-two-icon-sync-presence-design.md new file mode 100644 index 0000000..1a93d39 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-two-icon-sync-presence-design.md @@ -0,0 +1,289 @@ +# Two-icon sync-presence display — design + +**Date:** 2026-05-30 +**Status:** Approved (design); ready for implementation plan +**Supersedes (UI only):** the single-glyph `sync_status_icon` display and the +single-SVG run-tree rollup (`sync_local.svg` / `sync_cloud.svg`). `sync_cloud.svg` +is **retired** — the tree now uses `sync_nas.svg` so it speaks the same vocabulary +as the file rows. The backend sync states are unchanged. + +## 1. Problem + +The file browser currently shows sync status as a **single colour-coded glyph** +drawn from **ten** backend states (`pending`, `acquiring`, `retrying`, +`syncing`, `on_nas`, `synced`, `cleaned`, `failed`, `blocked_by_validation`, +`override_active`). Most of those distinctions describe the *machinery* of +syncing, which the operator does not act on. The only questions an operator +actually has about a file are: + +1. Is it **here** (local)? +2. Is it **on the NAS** (backed up)? +3. Is anything **wrong**? + +This design collapses the ten states into a **two-icon presence display** — one +icon for "local", one for "NAS" — using the existing `sync_local.svg` and +`sync_nas.svg` assets, with the **background colour** of each icon encoding that +location's state. The syncing machinery (pending / acquiring / syncing / +retrying) is hidden: an in-progress upload simply reads as "local, not backed up +yet". The backend states, queue, and `sync_state.json` schema are untouched — +this is a presentation change plus one additive backend read. + +## 2. Design principle + +One unified colour language across **both** files (two icons) and folders (one +rollup icon). The two per-file icons are always rendered in a fixed order — +**local on the left, NAS on the right** — so "which location" never depends on +colour (important for colour-blind operators). + +**blue = "here" · green = "safe on NAS" · gray = "absent" · red = "problem" · +amber = "held".** + +| Icon | Meaning | Colour | Token | +|---|---|---|---| +| **local** | here (only copy) | 🔵 solid blue | `--color-sync-local` | +| **local** | here, also on NAS (cache — safe to clear) | 🔵 faded blue | `--color-sync-cached` | +| **local** | not here, and that's fine | ⬜ gray | `--color-sync-absent` | +| **local** | gone / lost | 🔴 red | `--color-sync-problem` | +| **nas** | safe on NAS | 🟢 green | `--color-sync-safe` | +| **nas** | not on NAS yet | ⬜ gray | `--color-sync-absent` | +| **nas** | upload failed | 🔴 red | `--color-sync-problem` | +| **nas** | held by validation | 🟠 amber | `--color-sync-held` | + +`sync_local.svg` is itself blue (`#1b75bc`), so blue-for-local reinforces the +glyph rather than fighting it. The two blues both mean "here"; the **fade** is the +only difference and consistently means "also backed up" — and the NAS icon's +green is the primary "is it safe?" tell, so the fade is secondary. + +We introduce sync-specific semantic aliases (added to `design.py` + emitted by +`theme.build_root_css`) so the sync UI does not couple to incidental palette +edits: + +| Alias | Resolves to | Hex | +|---|---|---| +| `--color-sync-local` | solid blue | `--color-primary` `#1b75bc` | +| `--color-sync-cached` | faded blue | `--color-row-selected` `#dceaff` | +| `--color-sync-safe` | green | `--color-success` `#2e9e5b` | +| `--color-sync-absent` | tinted gray | a light `--color-muted` tint | +| `--color-sync-problem` | red | `--color-danger` `#d2492a` | +| `--color-sync-held` | amber | `--color-warning` `#e8a13a` | + +## 3. State taxonomy + +The display is driven by a **UI-only derived view enum**, `FileSyncView`, not by +the raw `SyncStatus`. Seven views cover every case: + +| # | `FileSyncView` | local bg | nas bg | Meaning | +|---|---|---|---|---| +| 1 | `LOCAL_ONLY` | 🔵 blue | ⬜ gray | Here, not backed up yet (incl. pending/syncing/retrying — detail hidden) | +| 2 | `SYNCED` | 🔵 faded blue | 🟢 green | Safe on NAS; local is now just a cache | +| 3 | `ON_NAS` | ⬜ gray | 🟢 green | Backed up; local copy reclaimed (cleaned) | +| 4 | `UPLOAD_FAILED` | 🔵 blue | 🔴 red | Local copy fine; NAS push failed (retries exhausted) | +| 5 | `BLOCKED` | 🔵 blue | 🟠 amber | Local fine; upload **held** by a hard validation finding | +| 6 | `MISSING` | 🔴 red | 🔴 red | Tracked, but gone locally **and** never confirmed on NAS | +| 7 | `NONE` | — | — | Untracked file / folder — no icons (neutral) | + +### 3.1 Mapping from backend signals + +`FileSyncView` is derived from four inputs available at the browse layer: + +- `on_disk: bool` — file exists locally (disk scan) +- `nas_verified: bool` — `FileSyncRecord.verified_at is not None` (confirmed on NAS) +- `record_present: bool` — a `FileSyncRecord` exists in `sync_state.json` +- `job_status: SyncStatus | None` — **live** sync-queue status for this path + (`failed` / `blocked_by_validation` / others), or `None` if not queued + +``` +def file_sync_view(*, on_disk, nas_verified, record_present, job_status): + if job_status == FAILED and on_disk and not nas_verified: return UPLOAD_FAILED + if job_status == BLOCKED_BY_VALIDATION and on_disk and not nas_verified: + return BLOCKED + if on_disk and nas_verified: return SYNCED + if on_disk and not nas_verified: return LOCAL_ONLY + if not on_disk and nas_verified: return ON_NAS + if not on_disk and record_present and not nas_verified: return MISSING + return NONE +``` + +Precedence note: an active `FAILED`/`BLOCKED` job overrides the plain +presence views so a stuck upload is never masked as "local only". + +## 4. Components + +### 4.1 `sync_status_icon.py` → two-icon pair + +Replace the single-glyph model. The module keeps its role as the **pure, +NiceGUI-free single source of truth** for sync presentation, now exposing: + +- `FileSyncView` (enum, UI-only). +- `file_sync_view(...)` — the pure mapping in §3.1. +- `sync_pair_props(view) -> {"local": IconCell, "nas": IconCell}` where each + `IconCell` is `{"svg", "bg_var", "badge", "tooltip", "aria_label"}`. `svg` is + the asset path (`/assets/sync_local.svg`, `/assets/sync_nas.svg`); `bg_var` is + the background token; `badge` is an optional corner overlay glyph (see §6); + `tooltip`/`aria_label` are the worded state. +- `sync_pair_icons(view)` — builds the NiceGUI two-icon row (local left, NAS + right), each icon an `` on a coloured, rounded background, with the + tooltip and `data-sync-*` attributes for tests. +- `sync_legend_entries()` — updated to the new six visible views (excludes + `NONE`), so the Files-header legend popover stays in lock-step with what rows + render. + +The old `_STATUS_TO_PROPS` glyph table, `STATUS_RETRYING`/`STATUS_OVERRIDE`/ +`STATUS_ACQUIRING`/`STATUS_SYNCING`/`STATUS_ON_NAS` constants, and the +`strict`/`retry_n`/`retry_m` machinery are removed (no consumer survives the +collapse). `override_active` and the retry counter disappear from the UI — they +were syncing-machinery detail the new model deliberately hides. + +### 4.2 `file_list.py` — Status cell + +`_render_row`'s Status `` calls `sync_pair_icons(view)` instead of +`sync_status_icon(status, strict=False)`. The row resolves its `FileSyncView` +from the `FileListEntry` (which gains `nas_verified`, `record_present`, and +`job_status` fields, or a single precomputed `sync_view` field — see §5). The +existing tombstone treatment (dim/italic, no "Open in OS") is unchanged; a +tombstone row is simply one whose view is `ON_NAS` or `MISSING`. + +### 4.3 `sync_rollup.py` — folder / tree rollup (single icon) + +A folder is a **summary**, so it renders **one** icon (not the two-icon pair) — +"is everything in here safe?". Two steps: + +1. **Worst-of reduction** over child `FileSyncView` values, with this severity + order (most-attention-worthy first): + + ``` + MISSING > UPLOAD_FAILED > BLOCKED > LOCAL_ONLY > SYNCED > ON_NAS + ``` + + Rationale: problems first; then **at-risk** local-only files (present but not + yet backed up) outrank fully-`SYNCED` ones; `ON_NAS` (done, reclaimed) is + calmest. `NONE` is ignored, as today. + +2. **Single-icon mapping** of the rolled-up view, via a new + `sync_rollup_icon(view) -> {"svg", "bg_var", "badge", "tooltip"}`: + + | Rolled-up view | SVG | bg | + |---|---|---| + | `SYNCED` / `ON_NAS` (all safe) | `sync_nas.svg` | 🟢 green | + | `LOCAL_ONLY` (not fully synced) | `sync_local.svg` | 🔵 blue | + | `BLOCKED` (a held file) | `sync_nas.svg` | 🟠 amber | + | `UPLOAD_FAILED` / `MISSING` (an error) | `sync_nas.svg` | 🔴 red | + | `NONE` (empty / untracked) | — | none | + + This is exactly the operator's stated model: fully synced → `sync_nas` green; + not synced → `sync_local` blue; error → `sync_nas` red (held → amber). + +### 4.4 Surfaces + +- **File rows** (`file_list.py`) — per-file **two-icon** pair (detail). +- **Run tree** (`browse.py` tree headers) — per-run **single** rollup icon + (`sync_rollup_icon`), replacing the old `sync_local.svg` / `sync_cloud.svg` + glyph. `sync_cloud.svg` is retired. +- **Metadata pane** — selected folder shows its **single** rollup icon. + +Files show both locations (detail); folders summarise to one icon. They share the +same colour language, so the summary never contradicts the detail beneath it. + +## 5. Backend changes (additive) + +### 5.1 Surface the "missing" view (free — `sync_state.json` only) + +Today `_tombstone_entries` **skips** records that are absent on disk and +unverified (`"An unverified, absent record is not a meaningful tombstone."`). +That silently drops genuinely lost files from the listing. Change: emit such +records as `MISSING` rows (tombstone, not openable) instead of skipping. Verified +absent records remain `ON_NAS`. `_file_state_from_record` is extended/replaced to +distinguish these. + +### 5.2 Propagate per-run failure from `creation.json` (Group B) + +**As built (refinement of the original "live queue" idea).** Per-file failure is +never persisted, and the only per-file granularity the queue offers is a per-*run* +job state reached through an accessor (`NASSyncClient.get_by_run_path`) that does +not exist. The robust, persisted source is the run's **`creation.json` +`sync_status`** (`pending` / `synced` / `cleaned` / `failed` / +`blocked_by_validation`), already maintained by the orchestrator and read +tolerantly in browse. So failure is surfaced at the **run** level and propagated +to that run's not-yet-verified on-disk files: + +- `_run_failure_flags(run_root)` reads `creation.json` (`read_msgspec_json(..., CreationJson)`, + try/except → `(False, False)` on absence/corruption) and returns + `(run_failed, run_blocked)`. +- `_file_state_from_record(record, *, on_disk, run_failed, run_blocked)` returns + `upload_failed` / `blocked` for an unverified on-disk file when the run is + failed / blocked (a **verified** file stays `synced` regardless), and `missing` + for an unverified, locally-absent record (the lost-file fix). +- The backend emits these discriminator strings on the existing per-file + `sync_status` field (`upload_failed`, `blocked`, `missing` join + `synced`/`syncing`/`acquiring`/`on_nas`); the UI maps the string to a + `FileSyncView` via `file_sync_view` — no new wire field, no UI re-derivation. + +Affects `scan_folder_sync` (per-file rows) and `_run_rollup_status` (tree rollup, +which short-circuits to `blocked`/`upload_failed` before the +`SyncStateWriter.rollup_state` fallback). + +No change to `FileSyncRecord` / `SyncStateJson` / `SyncStatus` enum — only reads. + +## 6. Accessibility + +Position (local left / NAS right) and the two distinct SVG shapes already encode +"which location" without colour. The remaining colour-only distinction is +*green-present vs red-problem vs amber-held* on the **same** icon. Mitigations: + +- **Every icon carries a worded `tooltip` and `aria-label`** (e.g. "On NAS", + "Upload failed — local copy safe", "Held by validation", "Missing — not found + locally or on NAS"). +- **Problem/held views add a small corner badge glyph** so red/amber are not + colour-only: `✕` for `UPLOAD_FAILED`/`MISSING`, `!` for `BLOCKED`. Green / blue + / gray ("everything is fine") stay colour-only — lower stakes. + +## 7. Testing impact + +- **Rewrite** `tests/unit/ui/test_sync_status_icon.py` for `file_sync_view` + (mapping table, all seven views) and `sync_pair_props` (colours, badges, + tooltips). Drop the `strict`/neutral-dash tests. +- **Update** `tests/unit/ui/test_sync_rollup.py` for the new `FileSyncView` + severity order, and add tests for `sync_rollup_icon` (the single-icon mapping: + `sync_nas` green/amber/red vs `sync_local` blue). +- **Rewrite** `tests/e2e/test_flow_05_browse_view_sync_icons.py`: it currently + asserts one `sync_local.svg` vs one `sync_cloud.svg` per run. Assert the new + tree rollup icon instead (`sync_nas.svg` for synced/cleared runs, `sync_local.svg` + for not-fully-synced) plus the per-view backgrounds, and the file-row two-icon + pair. Update the asset-200 check to `sync_local.svg` + `sync_nas.svg` + (`sync_cloud.svg` is no longer served). +- **New** unit tests for the `MISSING` surfacing in `browse.py` and the queue + status threading (`UPLOAD_FAILED` / `BLOCKED`). +- Existing sync backend / `sync_state_writer` / `pre_sync_gate` tests are + unaffected. + +## 8. Out of scope + +- Any change to the sync engine, queue, retry policy, validation gating, or + `sync_state.json` schema. +- The hidden orchestrator/staging surfaces (see `CLAUDE.md`) — untouched. +- An operator action to *retry* a failed upload from the file row (the row only + *reports*; retry stays automatic). Could be a follow-up. + +## 9. Token summary (for the plan) + +**Per-file two icons:** + +| View | local bg token | nas bg token | badge | +|---|---|---|---| +| `LOCAL_ONLY` | `--color-sync-local` | `--color-sync-absent` | — | +| `SYNCED` | `--color-sync-cached` | `--color-sync-safe` | — | +| `ON_NAS` | `--color-sync-absent` | `--color-sync-safe` | — | +| `UPLOAD_FAILED` | `--color-sync-local` | `--color-sync-problem` | `✕` on NAS | +| `BLOCKED` | `--color-sync-local` | `--color-sync-held` | `!` on NAS | +| `MISSING` | `--color-sync-problem` | `--color-sync-problem` | `✕` on both | +| `NONE` | — (no icons) | — | — | + +**Folder / tree single rollup icon (`sync_rollup_icon`):** + +| Rolled-up view | SVG | bg token | badge | +|---|---|---|---| +| `SYNCED` / `ON_NAS` | `sync_nas.svg` | `--color-sync-safe` | — | +| `LOCAL_ONLY` | `sync_local.svg` | `--color-sync-local` | — | +| `BLOCKED` | `sync_nas.svg` | `--color-sync-held` | `!` | +| `UPLOAD_FAILED` / `MISSING` | `sync_nas.svg` | `--color-sync-problem` | `✕` | +| `NONE` | — (no icon) | — | — | diff --git a/src/exlab_wizard/api/routers/browse.py b/src/exlab_wizard/api/routers/browse.py index ca35f9e..19ae338 100644 --- a/src/exlab_wizard/api/routers/browse.py +++ b/src/exlab_wizard/api/routers/browse.py @@ -489,6 +489,9 @@ def _relay_label_from_first_run(equipment_dir: Path, fallback: str) -> str: FILE_STATE_SYNCING = "syncing" FILE_STATE_SYNCED = "synced" FILE_STATE_ON_NAS = "on_nas" +FILE_STATE_UPLOAD_FAILED = "upload_failed" +FILE_STATE_BLOCKED = "blocked" +FILE_STATE_MISSING = "missing" def _find_run_root(folder: Path) -> Path | None: @@ -528,23 +531,61 @@ def _read_sync_state(run_root: Path) -> Any: return None -def _file_state_from_record(record: Any | None, *, on_disk: bool) -> str | None: - """Map a ``sync_state.json`` record + on-disk presence to a GUI state. +def _run_failure_flags(run_root: Path | None) -> tuple[bool, bool]: + """Return (run_failed, run_blocked) from the run's creation.json sync_status. + + The per-file sync queue never persists per-file failure; failure is a + per-run signal carried by ``creation.json``'s ``sync_status`` field + (``failed`` / ``blocked_by_validation``). Read tolerantly: an absent or + undecodable ``creation.json`` yields no failure signal. + """ + if run_root is None: + return (False, False) + try: + payload = read_msgspec_json(creation_json_path(run_root), CreationJson) + except Exception: # absent / undecodable creation.json -> no failure signal + return (False, False) + status = getattr(payload, "sync_status", None) + return (status == "failed", status == "blocked_by_validation") - The five-state table (operator-free per-file NAS sync design, - 2026-05-21): - * not recorded + on disk -> ``acquiring`` - * recorded, unverified + disk -> ``syncing`` - * recorded, verified + on disk -> ``synced`` - * recorded, verified + absent -> ``on_nas`` (tombstone) +def _file_state_from_record( + record: Any | None, + *, + on_disk: bool, + run_failed: bool = False, + run_blocked: bool = False, +) -> str | None: + """Map a sync_state.json record + on-disk presence to a GUI discriminator. + + ``run_failed`` / ``run_blocked`` come from the run's persisted + ``creation.json`` ``sync_status`` and apply only to an on-disk file that + has not yet verified to the NAS (the files a failed/blocked run did not + finish uploading). A verified file stays ``synced`` regardless. + + * not recorded + on disk -> ``acquiring`` + * recorded, verified + on disk -> ``synced`` + * recorded, unverified + disk -> ``blocked`` / ``upload_failed`` / + ``syncing`` (depending on the run flags) + * recorded, verified + absent -> ``on_nas`` (tombstone) + * recorded, unverified + absent -> ``missing`` (lost: tracked, gone + locally, never verified) """ if record is None: return FILE_STATE_ACQUIRING if on_disk else None verified = getattr(record, "verified_at", None) is not None if on_disk: - return FILE_STATE_SYNCED if verified else FILE_STATE_SYNCING - return FILE_STATE_ON_NAS if verified else None + if verified: + return FILE_STATE_SYNCED + if run_blocked: + return FILE_STATE_BLOCKED + if run_failed: + return FILE_STATE_UPLOAD_FAILED + return FILE_STATE_SYNCING + # absent on disk + if verified: + return FILE_STATE_ON_NAS + return FILE_STATE_MISSING # tracked, gone locally, never verified = lost def _per_file_sync_status(path: Path) -> str | None: @@ -565,7 +606,10 @@ def _per_file_sync_status(path: Path) -> str | None: return None rel = _run_relative_posix(run_root, path) record = state.files.get(rel) if rel is not None else None - return _file_state_from_record(record, on_disk=True) + run_failed, run_blocked = _run_failure_flags(run_root) + return _file_state_from_record( + record, on_disk=True, run_failed=run_failed, run_blocked=run_blocked + ) def _run_relative_posix(run_root: Path, path: Path) -> str | None: @@ -673,13 +717,21 @@ def _build_run_node(run_dir: Path, *, kind: str) -> RunNode: def _run_rollup_status(run_dir: Path) -> str | None: - """Return a run's derived ``RunSyncState`` rollup value, or ``None``. + """Return a run's rollup discriminator for the tree icon, or ``None``. - Reads ``sync_state.json`` and applies the pure - :meth:`SyncStateWriter.rollup_state` derivation. + A run whose ``creation.json`` ``sync_status`` is ``blocked_by_validation`` + / ``failed`` folds to ``blocked`` / ``upload_failed`` (so the tree carries + the held/problem rollup). Otherwise the rollup is derived from + ``sync_state.json`` via the pure :meth:`SyncStateWriter.rollup_state` + derivation (``syncing`` / ``synced`` / ``cleared``). """ from exlab_wizard.cache.sync_state_writer import SyncStateWriter + run_failed, run_blocked = _run_failure_flags(run_dir) + if run_blocked: + return FILE_STATE_BLOCKED + if run_failed: + return FILE_STATE_UPLOAD_FAILED try: state = SyncStateWriter().read_sync(run_dir) except Exception as exc: # pragma: no cover -- defensive @@ -771,6 +823,7 @@ def scan_folder_sync(folder_path: str, config: Any) -> FolderResponse: # whole folder (operator-free per-file NAS sync design, 2026-05-21). run_root = _find_run_root(path) sync_state = _read_sync_state(run_root) if run_root is not None else None + run_failed, run_blocked = _run_failure_flags(run_root) on_disk_rel: set[str] = set() for entry in sorted(scandir_entries, key=lambda e: e.name): try: @@ -792,7 +845,16 @@ def scan_folder_sync(folder_path: str, config: Any) -> FolderResponse: is_dir=is_dir, size_bytes=None if is_dir else stat.st_size, modified_iso=dt_to_iso(datetime.fromtimestamp(stat.st_mtime, tz=UTC)), - sync_status=(None if is_dir else _file_state_from_record(record, on_disk=True)), + sync_status=( + None + if is_dir + else _file_state_from_record( + record, + on_disk=True, + run_failed=run_failed, + run_blocked=run_blocked, + ) + ), keep_local=bool(getattr(record, "keep_local", False)), ) ) @@ -832,8 +894,10 @@ def _tombstone_entries( if abs_path.parent != folder: continue state = _file_state_from_record(record, on_disk=False) + # verified-absent -> "on_nas"; unverified-absent -> "missing" (lost file). + # state is None only for no-record inputs, which never occur in this loop + # (it iterates recorded files), so the guard below is a safety net. if state is None: - # An unverified, absent record is not a meaningful tombstone. continue out.append( FolderEntry( diff --git a/src/exlab_wizard/ui/components/file_list.py b/src/exlab_wizard/ui/components/file_list.py index 1ba89fa..e89ee49 100644 --- a/src/exlab_wizard/ui/components/file_list.py +++ b/src/exlab_wizard/ui/components/file_list.py @@ -27,7 +27,7 @@ from exlab_wizard.ui.components.empty_state import empty_state from exlab_wizard.ui.components.file_type_icon import file_type_icon -from exlab_wizard.ui.components.sync_status_icon import STATUS_ON_NAS, sync_status_icon +from exlab_wizard.ui.components.sync_status_icon import file_sync_view, sync_pair_icons from exlab_wizard.ui.pages.staging import format_bytes # Action discriminators consumed by the on_context_menu callback. @@ -267,10 +267,11 @@ def _render_row( # (Phase 2); the border-bottom rule is the row's only constant style. state_style = row_background(entry, is_selected=is_selected, is_new=is_new, index=index) row_style = f"{state_style} border-bottom: 1px solid var(--color-rule);".strip() - # The Status cell renders an icon (tolerant: an untracked file or a - # folder carries no status and shows a neutral dash); a tombstone with - # no recorded status still reads as the "On NAS" cloud. - status_for_icon = entry.sync_status or (STATUS_ON_NAS if entry.tombstone else None) + # The Status cell renders the two-icon (local + NAS) presence pair. + # Tombstones carry their own discriminator ("on_nas" or "missing"); a + # tombstone with no recorded status still reads as "on_nas". An untracked + # file / folder carries no status and the pair renders nothing. + view = file_sync_view(entry.sync_status or ("on_nas" if entry.tombstone else None)) keep_local_attr = ' data-keep-local="true"' if entry.keep_local else "" tombstone_attr = ' data-tombstone="true"' if entry.tombstone else "" selected_attr = ' data-selected="true"' if is_selected else "" @@ -304,7 +305,7 @@ def _render_row( with ui.element("td").classes("p-2"): ui.label(modified_text) with ui.element("td").classes("p-2"): - sync_status_icon(status_for_icon, strict=False) + sync_pair_icons(view) if on_context_menu is not None: with ui.context_menu().props( f'data-testid="file-context-menu" data-path="{entry.path}"' diff --git a/src/exlab_wizard/ui/components/metadata_pane.py b/src/exlab_wizard/ui/components/metadata_pane.py index 78cb68c..d135eaf 100644 --- a/src/exlab_wizard/ui/components/metadata_pane.py +++ b/src/exlab_wizard/ui/components/metadata_pane.py @@ -17,7 +17,12 @@ from exlab_wizard.ui.components.empty_state import empty_state from exlab_wizard.ui.components.file_type_icon import file_type_icon -from exlab_wizard.ui.components.sync_status_icon import STATUS_ON_NAS, sync_status_icon +from exlab_wizard.ui.components.sync_status_icon import ( + FileSyncView, + file_sync_view, + sync_pair_icons, + sync_rollup_icon, +) # Node-kind discriminators consumed by the dispatcher. NODE_KIND_EQUIPMENT = "equipment" @@ -130,12 +135,16 @@ def _kv(key: str, value: Any) -> None: # pragma: no cover -- NiceGUI render, dr ) -def _kv_sync(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI render, driven by e2e - """Key/value row whose value is a sync-status icon (tolerant). +def _kv_sync( + key: str, status: Any, render_icon: Callable[[FileSyncView], Any] +) -> None: # pragma: no cover -- NiceGUI render, driven by e2e + """Key/value row whose value is a sync icon (file pair or folder rollup). Mirrors :func:`_kv`'s label column but renders the status through the - tolerant icon path (``strict=False``): a ``None`` / unknown status shows - a neutral dash rather than raising (spec §4.5). + tolerant presentation map: a ``None`` / unknown status renders nothing + rather than raising (spec §4.5). ``render_icon`` is the icon renderer to + use for the value cell -- :func:`sync_pair_icons` for a file's two-icon + pair, :func:`sync_rollup_icon` for a folder's single rollup icon. """ try: from nicegui import ui @@ -143,7 +152,17 @@ def _kv_sync(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI rend return with ui.row().classes("items-center w-full"): ui.label(f"{key}:").style("color: var(--color-muted); width: 12rem; min-width: 12rem;") - sync_status_icon(status, strict=False) + render_icon(file_sync_view(status)) + + +def _kv_sync_pair(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI render + """Key/value row whose value is a file's two-icon (local + NAS) pair.""" + _kv_sync(key, status, sync_pair_icons) + + +def _kv_sync_rollup(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI render + """Key/value row whose value is a folder's single rollup sync icon.""" + _kv_sync(key, status, sync_rollup_icon) def _render_selected_file_card( @@ -183,16 +202,16 @@ def _render_selected_file_card( ) if is_folder: _kv("Items", payload.get("item_count")) - _kv_sync("Sync", payload.get("rollup")) + _kv_sync_rollup("Sync", payload.get("rollup")) _kv("Path", payload.get("path")) else: tombstone = bool(payload.get("tombstone")) if not tombstone: _kv("Size", payload.get("size")) _kv("Modified", payload.get("modified")) - _kv_sync( + _kv_sync_pair( "Sync", - payload.get("sync_status") or (STATUS_ON_NAS if tombstone else None), + payload.get("sync_status") or ("on_nas" if tombstone else None), ) _kv("Path", payload.get("path")) if tombstone: diff --git a/src/exlab_wizard/ui/components/sync_rollup.py b/src/exlab_wizard/ui/components/sync_rollup.py index 9f9ce85..3efbd02 100644 --- a/src/exlab_wizard/ui/components/sync_rollup.py +++ b/src/exlab_wizard/ui/components/sync_rollup.py @@ -1,61 +1,50 @@ -"""Folder-level sync-status rollup (GUI/Orchestrator Redesign §4.6). - -When a folder is selected in the centre file list, the metadata sub-card -shows a single "worst-of" rollup of its children's per-file sync states. -This module owns that reduction as a pure, NiceGUI-free function so the -ordering is testable in isolation. - -**Why a UI-only ordering.** The per-file states are :class:`SyncStatus` -values (``pending`` / ``synced`` / ``cleaned`` / ``failed`` / -``blocked_by_validation``). ``SyncStatus`` is a schema-committed -``StrEnum`` that deliberately carries *no* severity order -- the enum -module forbids reordering without a coordinated schema-version bump -(constants/enums.py). The rollup is a presentation concern, so the -severity ranking lives here, beside its only consumer, rather than on the -enum. If a second consumer ever needs the same order, promoting it onto -``SyncStatus`` becomes a separate, coordinated change (spec §11). +"""Folder/run-level sync-status rollup (two-icon presence model, 2026-05-30). + +When a run row (left tree) or a folder (metadata pane) is summarised, it carries +a single "worst-of" rollup icon reduced from its children's per-file sync +discriminators. This module owns that reduction as a pure, NiceGUI-free function +so the severity ordering is testable in isolation. + +The reduction is over :class:`FileSyncView` (the UI presentation view), not the +schema-committed ``SyncStatus`` enum: severity is a presentation concern, so it +lives here beside its only consumer rather than on the wire enum. """ from __future__ import annotations from collections.abc import Iterable -from exlab_wizard.constants import SyncStatus - -# Most-attention-worthy first. A folder is summarised by the highest-ranked -# state any child carries: a single failed file dominates a folder of -# otherwise-synced files. ``cleaned`` (data on NAS, local copy gone) ranks -# lowest -- it is the quiet terminal "done" state. -_SEVERITY_ORDER: tuple[str, ...] = ( - SyncStatus.FAILED.value, - SyncStatus.BLOCKED_BY_VALIDATION.value, - SyncStatus.PENDING.value, - SyncStatus.SYNCED.value, - SyncStatus.CLEANED.value, +from exlab_wizard.ui.components.sync_status_icon import FileSyncView, file_sync_view + +# Most-attention-worthy first. A folder/run is summarised by the highest-ranked +# view any child carries. +_SEVERITY_ORDER: tuple[FileSyncView, ...] = ( + FileSyncView.MISSING, + FileSyncView.UPLOAD_FAILED, + FileSyncView.BLOCKED, + FileSyncView.LOCAL_ONLY, + FileSyncView.SYNCED, + FileSyncView.ON_NAS, ) -_SEVERITY_RANK: dict[str, int] = {value: rank for rank, value in enumerate(_SEVERITY_ORDER)} +_SEVERITY_RANK: dict[FileSyncView, int] = {v: i for i, v in enumerate(_SEVERITY_ORDER)} def sync_rollup(statuses: Iterable[str | None]) -> str | None: - """Reduce per-file sync statuses to a single worst-of rollup value. - - Returns the highest-severity recognised status among ``statuses`` - (``failed > blocked_by_validation > pending > synced > cleaned``), or - ``None`` when there is nothing to roll up -- an empty input, or one - holding only ``None`` / unrecognised values. ``None`` and unknown - values are ignored rather than raising: a folder of unstatused files - has no meaningful rollup, and the metadata card renders that as a - neutral dash via the tolerant icon path (sync_status_icon, §4.5). + """Reduce per-file sync discriminators to a single worst-of view value. + + Returns the highest-severity recognised view's value string + (``missing > upload_failed > blocked > local_only > synced > on_nas``), + or ``None`` when there is nothing to roll up (empty, or only ``None`` / + unrecognised values map to :attr:`FileSyncView.NONE`, which is ignored). """ - best: str | None = None - best_rank = len(_SEVERITY_ORDER) # worse-than-any sentinel + best: FileSyncView | None = None + best_rank = len(_SEVERITY_ORDER) for status in statuses: - if status is None: - continue - rank = _SEVERITY_RANK.get(status) - if rank is None: + view = file_sync_view(status) + rank = _SEVERITY_RANK.get(view) + if rank is None: # NONE continue if rank < best_rank: best_rank = rank - best = status - return best + best = view + return best.value if best is not None else None diff --git a/src/exlab_wizard/ui/components/sync_status_icon.py b/src/exlab_wizard/ui/components/sync_status_icon.py index e5c09ed..c2501cd 100644 --- a/src/exlab_wizard/ui/components/sync_status_icon.py +++ b/src/exlab_wizard/ui/components/sync_status_icon.py @@ -1,205 +1,300 @@ -"""Sync-status icon component (Frontend Spec §3.2, §10.5.1). - -Distinct visual states with a fixed color mapping: - -* ``pending`` -- ``--color-muted`` -* ``acquiring`` -- ``--color-muted`` -* ``retrying`` (with N/M) -- ``--color-info`` -* ``syncing`` -- ``--color-info`` -* ``synced`` -- ``--color-success`` -* ``cleaned`` -- ``--color-success`` -* ``on_nas`` -- ``--color-muted`` -* ``failed`` -- ``--color-danger`` -* ``blocked_by_validation`` -- ``--color-warning`` -* ``override_active`` -- ``--color-info`` - -The ``acquiring`` / ``syncing`` / ``on_nas`` states are the per-file GUI -display states from the operator-free per-file NAS sync design -(2026-05-21): a file still settling, a file mid-transfer, and an "On -NAS" tombstone whose local copy has been cleared. - -The component returns a dict suitable for a NiceGUI icon factory; the -layout (icon + optional ``(N/M)`` retry counter) is the caller's concern -so the icon can be embedded in a tree row, a detail-pane title bar, or a -staging-panel row identically. +"""Two-icon sync-presence model + renderers (2026-05-30 design). + +Each file shows a fixed **local-left / NAS-right** icon pair; runs and folders +show a single colour-coded rollup icon. The backend stays the state authority +and emits a discriminator string; this module is a pure presentation map: +``file_sync_view`` collapses the string to a :class:`FileSyncView`, then +``sync_pair_props`` / ``sync_rollup_icon_props`` map the view to icon + colour +props. Colour language: **blue = here · green = safe on NAS · gray = absent · +red = problem · amber = held.** NiceGUI renderers (``sync_pair_icons`` / +``sync_rollup_icon``) live below the pure layer and import NiceGUI lazily. """ from __future__ import annotations -from typing import Any, Final, Literal +from enum import StrEnum +from typing import Any, Final + +SYNC_LOCAL_SVG: Final[str] = "/assets/sync_local.svg" +SYNC_NAS_SVG: Final[str] = "/assets/sync_nas.svg" -from exlab_wizard.constants import SyncStatus -from exlab_wizard.logging import get_logger -_log = get_logger(__name__) +class FileSyncView(StrEnum): + """UI-only collapsed view of a file/run's sync state (presentation, not wire).""" + LOCAL_ONLY = "local_only" + SYNCED = "synced" + ON_NAS = "on_nas" + UPLOAD_FAILED = "upload_failed" + BLOCKED = "blocked" + MISSING = "missing" + NONE = "none" -# UI-only icon kinds: ``retrying`` and ``override_active`` are derived -# in the UI from the SyncStatus + sync-job state, not wire enum values. -type SyncStatusIconExtraKind = Literal["retrying", "override_active"] -type SyncStatusOrIcon = SyncStatus | SyncStatusIconExtraKind | str -STATUS_RETRYING: Final[str] = "retrying" -STATUS_OVERRIDE: Final[str] = "override_active" +# Backend discriminator string -> view. Backend is the state authority; the UI +# only maps. Unknown / None -> NONE (render nothing). +_STATUS_TO_VIEW: Final[dict[str, FileSyncView]] = { + "synced": FileSyncView.SYNCED, + "acquiring": FileSyncView.LOCAL_ONLY, + "syncing": FileSyncView.LOCAL_ONLY, + "pending": FileSyncView.LOCAL_ONLY, + "local_only": FileSyncView.LOCAL_ONLY, + "on_nas": FileSyncView.ON_NAS, + "cleaned": FileSyncView.ON_NAS, + "cleared": FileSyncView.ON_NAS, + "upload_failed": FileSyncView.UPLOAD_FAILED, + "failed": FileSyncView.UPLOAD_FAILED, + "blocked": FileSyncView.BLOCKED, + "blocked_by_validation": FileSyncView.BLOCKED, + "missing": FileSyncView.MISSING, +} + -# Per-file GUI display states (operator-free per-file NAS sync design, -# 2026-05-21). These mirror the discriminators emitted by -# ``api.routers.browse._file_state_from_record``. -STATUS_ACQUIRING: Final[str] = "acquiring" -STATUS_SYNCING: Final[str] = "syncing" -STATUS_ON_NAS: Final[str] = "on_nas" +def file_sync_view(status: str | None) -> FileSyncView: + """Map a backend sync discriminator string to a :class:`FileSyncView`.""" + if not status: + return FileSyncView.NONE + return _STATUS_TO_VIEW.get(str(status), FileSyncView.NONE) -_STATUS_TO_PROPS: dict[str, dict[str, str]] = { - SyncStatus.PENDING.value: { - "icon_name": "schedule", - "color_var": "--color-muted", - "tooltip": "Queued for sync", +# Per-view two-icon props. Each cell: svg, bg_var, badge, tooltip, faded. +_PAIR_PROPS: Final[dict[FileSyncView, dict[str, dict[str, Any]]]] = { + FileSyncView.LOCAL_ONLY: { + "local": { + "svg": SYNC_LOCAL_SVG, + "bg_var": "--color-sync-local", + "badge": "", + "tooltip": "Stored locally — not backed up yet", + "faded": False, + }, + "nas": { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-absent", + "badge": "", + "tooltip": "Not on the NAS yet", + "faded": True, + }, }, - STATUS_ACQUIRING: { - "icon_name": "edit_note", - "color_var": "--color-muted", - "tooltip": "Acquiring -- new file, still settling", + FileSyncView.SYNCED: { + "local": { + "svg": SYNC_LOCAL_SVG, + "bg_var": "--color-sync-cached", + "badge": "", + "tooltip": "Local cache — safe to clear (backed up)", + "faded": False, + }, + "nas": { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-safe", + "badge": "", + "tooltip": "Backed up on the NAS", + "faded": False, + }, }, - STATUS_RETRYING: { - "icon_name": "history", - "color_var": "--color-info", - "tooltip": "Retrying with backoff", + FileSyncView.ON_NAS: { + "local": { + "svg": SYNC_LOCAL_SVG, + "bg_var": "--color-sync-absent", + "badge": "", + "tooltip": "Local copy reclaimed", + "faded": True, + }, + "nas": { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-safe", + "badge": "", + "tooltip": "Backed up on the NAS", + "faded": False, + }, }, - STATUS_SYNCING: { - "icon_name": "sync", - "color_var": "--color-info", - "tooltip": "Syncing -- settled, transferring to NAS", + FileSyncView.UPLOAD_FAILED: { + "local": { + "svg": SYNC_LOCAL_SVG, + "bg_var": "--color-sync-local", + "badge": "", + "tooltip": "Stored locally — safe", + "faded": False, + }, + "nas": { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-problem", + "badge": "✕", + "tooltip": "Upload to NAS failed", + "faded": False, + }, }, - STATUS_ON_NAS: { - "icon_name": "cloud", - "color_var": "--color-muted", - "tooltip": "On NAS -- local copy cleared, data on NAS only", + FileSyncView.BLOCKED: { + "local": { + "svg": SYNC_LOCAL_SVG, + "bg_var": "--color-sync-local", + "badge": "", + "tooltip": "Stored locally — safe", + "faded": False, + }, + "nas": { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-held", + "badge": "!", + "badge_bg": "--color-sync-held", + "tooltip": "Upload held by a validation finding", + "faded": False, + }, + }, + FileSyncView.MISSING: { + "local": { + "svg": SYNC_LOCAL_SVG, + "bg_var": "--color-sync-problem", + "badge": "✕", + "tooltip": "Missing — not found locally", + "faded": False, + }, + "nas": { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-problem", + "badge": "✕", + "tooltip": "Missing — not on the NAS", + "faded": False, + }, + }, +} + + +def sync_pair_props(view: FileSyncView) -> dict[str, dict[str, Any]] | None: + """Return ``{"local": cell, "nas": cell}`` for a file's two-icon pair, or None.""" + props = _PAIR_PROPS.get(view) + if props is None: + return None + # Return a deep-ish copy so callers can't mutate the table. + return {side: dict(cell) for side, cell in props.items()} + + +# Per-view single rollup icon (runs / folders): svg, bg_var, badge, tooltip. +_ROLLUP_PROPS: Final[dict[FileSyncView, dict[str, str]]] = { + FileSyncView.SYNCED: { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-safe", + "badge": "", + "tooltip": "Fully backed up on the NAS", }, - SyncStatus.SYNCED.value: { - "icon_name": "check_circle", - "color_var": "--color-success", - "tooltip": "Synced and verified at NAS", + FileSyncView.ON_NAS: { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-safe", + "badge": "", + "tooltip": "Fully backed up on the NAS", }, - SyncStatus.CLEANED.value: { - "icon_name": "cloud_done", - "color_var": "--color-success", - "tooltip": "Synced and locally cleaned; data on NAS only", + FileSyncView.LOCAL_ONLY: { + "svg": SYNC_LOCAL_SVG, + "bg_var": "--color-sync-local", + "badge": "", + "tooltip": "Not fully synced — local files remain", }, - SyncStatus.FAILED.value: { - "icon_name": "error", - "color_var": "--color-danger", - "tooltip": "Sync failed; retry budget exhausted", + FileSyncView.BLOCKED: { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-held", + "badge": "!", + "badge_bg": "--color-sync-held", + "tooltip": "Sync held by a validation finding", }, - SyncStatus.BLOCKED_BY_VALIDATION.value: { - "icon_name": "warning", - "color_var": "--color-warning", - "tooltip": "Hard-tier validation finding gates sync", + FileSyncView.UPLOAD_FAILED: { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-problem", + "badge": "✕", + "tooltip": "Sync error", }, - STATUS_OVERRIDE: { - "icon_name": "lock_open", - "color_var": "--color-info", - "tooltip": "Sync allowed under operator override", + FileSyncView.MISSING: { + "svg": SYNC_NAS_SVG, + "bg_var": "--color-sync-problem", + "badge": "✕", + "tooltip": "Sync error", }, } -# Neutral props returned for an unknown / ``None`` status in tolerant mode: -# a muted dash with no icon. Keys mirror the normal props shape so callers -# (``sync_status_icon`` render fn, file-list Status cell, legend) can treat -# both paths identically. -_NEUTRAL_PROPS: Final[dict[str, str]] = { - "icon_name": "", - "color_var": "--color-muted", - "tooltip": "", -} +def sync_rollup_icon_props(view: FileSyncView) -> dict[str, str] | None: + """Return single-icon props for a run/folder rollup, or None for NONE.""" + props = _ROLLUP_PROPS.get(view) + return dict(props) if props is not None else None -def sync_legend_entries() -> list[dict[str, str]]: - """Return the sync-status legend rows, in declaration order (Phase 5). - - One dict per state in ``_STATUS_TO_PROPS`` -- ``{"status", "icon_name", - "color_var", "tooltip"}`` -- so the Files-header legend popover lists - exactly the icons + meanings the file list / metadata pane render and can't - drift from them (``_STATUS_TO_PROPS`` is the single source of truth). Pure - so it is testable without NiceGUI. - """ - - return [{"status": status, **props} for status, props in _STATUS_TO_PROPS.items()] - - -def sync_status_props( - status: SyncStatusOrIcon | None, - *, - retry_n: int | None = None, - retry_m: int | None = None, - strict: bool = True, -) -> dict[str, Any]: - """Compute icon + tooltip + retry-counter for a sync status. - - The ``retry_n``/``retry_m`` annotations are rendered only when - ``status == "retrying"`` (Frontend §10.5.1). - - ``strict`` (default ``True``) preserves the original contract: an unknown - status raises ``ValueError`` so a genuinely wrong value is caught loudly. - With ``strict=False`` an unknown *or* ``None`` status returns neutral - props (a muted dash, no icon) instead of raising -- used by callers that - render optional per-file / per-folder status (the file-list Status cell, - the folder rollup, the legend) where ``None`` is legitimate, not a bug. - """ - - key = ( - "" if status is None else (status.value if isinstance(status, SyncStatus) else str(status)) +def _icon_cell(cell: dict[str, Any], *, side: str) -> None: # pragma: no cover -- NiceGUI render + from nicegui import ui + + box = ( + ui.element("span") + .props(f'data-sync-cell="{side}" data-sync-bg="{cell["bg_var"]}"') + .style( + f"position: relative; display: inline-flex; align-items: center; " + f"justify-content: center; width: 1.5rem; height: 1.5rem; " + f"border-radius: var(--radius-sm); background: var({cell['bg_var']});" + ) + .tooltip(cell["tooltip"]) ) - if key not in _STATUS_TO_PROPS: - if strict: - raise ValueError( - f"unknown sync status {status!r}: must be one of {sorted(_STATUS_TO_PROPS)}", + with box: + opacity = "0.45" if cell.get("faded") else "1" + ui.element("img").props(f'src="{cell["svg"]}" alt="{cell["tooltip"]}"').style( + f"width: 1rem; height: 1rem; opacity: {opacity};" + ) + if cell["badge"]: + # Badge dot matches the cell's alert hue: red for a problem, amber + # for a held cell -- never red-on-amber (the white glyph stays legible + # on both). Defaults to red for problem cells that omit the key. + badge_bg = cell.get("badge_bg", "--color-sync-problem") + ui.label(cell["badge"]).props('data-sync-badge="true"').style( + "position: absolute; top: -3px; right: -3px; font-size: 0.6rem; " + "line-height: 1; font-weight: 700; color: var(--color-surface); " + f"background: var({badge_bg}); border-radius: 50%; " + "width: 0.8rem; height: 0.8rem; display: flex; align-items: center; " + "justify-content: center;" ) - return {**_NEUTRAL_PROPS, "status": key, "retry_label": ""} - base = dict(_STATUS_TO_PROPS[key]) - base["status"] = key - if key == STATUS_RETRYING and retry_n is not None and retry_m is not None: - base["retry_label"] = f"({retry_n}/{retry_m})" - base["tooltip"] = f"Retry {retry_n} of {retry_m}, awaiting backoff" - else: - base["retry_label"] = "" - return base - - -def sync_status_icon( - status: SyncStatusOrIcon | None, - *, - retry_n: int | None = None, - retry_m: int | None = None, - strict: bool = True, -) -> Any: - """Build a NiceGUI row containing the icon and optional retry counter. - - ``strict`` is forwarded to :func:`sync_status_props`; with - ``strict=False`` an unknown / ``None`` status renders a muted dash - instead of raising (used by the file-list Status cell and the legend). - """ - - props = sync_status_props(status, retry_n=retry_n, retry_m=retry_m, strict=strict) + + +def sync_pair_icons(view: FileSyncView) -> Any: # pragma: no cover -- NiceGUI render + """Render the per-file two-icon (local + NAS) row, or nothing for NONE.""" + props = sync_pair_props(view) try: from nicegui import ui except Exception: return props + if props is None: + return ui.element("span").props('data-sync-view="none"') + row = ( + ui.row() + .classes("items-center") + .props(f'data-sync-view="{view.value}"') + .style("gap: 0.3rem;") + ) + with row: + _icon_cell(props["local"], side="local") + _icon_cell(props["nas"], side="nas") + return row + - row = ui.row().classes("items-center").style("gap: 0.25rem;") +def sync_rollup_icon(view: FileSyncView) -> Any: # pragma: no cover -- NiceGUI render + """Render the single run/folder rollup icon, or nothing for NONE.""" + props = sync_rollup_icon_props(view) + try: + from nicegui import ui + except Exception: + return props + if props is None: + return ui.element("span").props('data-sync-view="none"') + cell = {**props, "faded": False} + row = ui.row().classes("items-center").props(f'data-sync-view="{view.value}"') with row: - if props["icon_name"]: - ui.icon(props["icon_name"]).style( - f"color: var({props['color_var']}); font-size: 1rem;" - ).tooltip(props["tooltip"]) - else: - # Neutral / unknown status (tolerant mode): a muted dash. - ui.label("-").style(f"color: var({props['color_var']});") - if props["retry_label"]: - ui.label(props["retry_label"]).style( - "font-family: var(--font-mono); " - "font-size: var(--text-xs); " - f"color: var({props['color_var']});" - ) + _icon_cell(cell, side="rollup") return row + + +def sync_legend_entries() -> list[dict[str, str]]: + """Return legend rows (one per visible view) for the Files-header popover.""" + return [ + {"view": v.value, "tooltip": _ROLLUP_PROPS.get(v, {}).get("tooltip", "")} + for v in ( + FileSyncView.LOCAL_ONLY, + FileSyncView.SYNCED, + FileSyncView.ON_NAS, + FileSyncView.UPLOAD_FAILED, + FileSyncView.BLOCKED, + FileSyncView.MISSING, + ) + ] diff --git a/src/exlab_wizard/ui/components/tree.py b/src/exlab_wizard/ui/components/tree.py index da6c3fd..4086378 100644 --- a/src/exlab_wizard/ui/components/tree.py +++ b/src/exlab_wizard/ui/components/tree.py @@ -9,17 +9,21 @@ * Run node (test) -- dimmed styling + ``TestRun_`` prefix in warning-tier color + a *"Test"* pill. -Run rows also carry a small **sync icon** to the left of the label: - -* ``sync_local.svg`` -- run data is still on local disk (rollup - ``syncing`` / ``synced``, any state other than ``cleared``). -* ``sync_cloud.svg`` -- the run's staging copy has been cleared - (rollup ``cleared``); only the ``.exlab-wizard/`` cache subtree - remains on disk (§7.1.10). - -The run-node rollup is derived from the run's ``sync_state.json`` by the -browse router (operator-free per-file NAS sync design, 2026-05-21); -``RunNode.sync_status`` carries a :class:`RunSyncState` value. +Run rows also carry a small colour-coded **rollup sync icon** to the left +of the label (two-icon sync-presence design, 2026-05-30): + +* ``sync_local.svg`` on a blue background -- run data is still on local + disk (not fully backed up). +* ``sync_nas.svg`` on a green background -- the run is fully backed up on + the NAS. +* ``sync_nas.svg`` on a red / amber background with a ``✕`` / ``!`` badge + -- a problem (upload failed) or a held (blocked-by-validation) run. + +The run-node rollup discriminator is derived from the run's +``sync_state.json`` + persisted ``creation.json`` failure status by the +browse router; ``RunNode.sync_status`` carries that string, which +:func:`~exlab_wizard.ui.components.sync_status_icon.file_sync_view` maps +to a :class:`~exlab_wizard.ui.components.sync_status_icon.FileSyncView`. ``.exlab-wizard/`` folders are hidden by default (Frontend §13.1) and hidden filtering is the caller's concern. @@ -35,8 +39,12 @@ from dataclasses import dataclass, field from typing import Any -from exlab_wizard.constants.enums import RunKind, RunSyncState, TreeProjectStatus +from exlab_wizard.constants.enums import RunKind, TreeProjectStatus from exlab_wizard.logging import get_logger +from exlab_wizard.ui.components.sync_status_icon import ( + file_sync_view, + sync_rollup_icon_props, +) _log = get_logger(__name__) @@ -79,9 +87,11 @@ def _node_type_props(kind: str) -> tuple[str, str]: KIND_RUN_TEST: "run", } -# Static URLs served by ``ui/theme.py:register_static_assets``. +# Static URLs served by ``ui/theme.py:register_static_assets``. The rollup +# props come from :mod:`sync_status_icon`; these names are retained as the +# module's public asset-URL surface (asserted by the tree tests). SYNC_ICON_LOCAL_URL = "/assets/sync_local.svg" -SYNC_ICON_CLOUD_URL = "/assets/sync_cloud.svg" +SYNC_ICON_NAS_URL = "/assets/sync_nas.svg" @dataclass(frozen=True) @@ -249,20 +259,19 @@ def build_nodes( return nodes -def _sync_icon_url(node: TreeNode) -> str | None: - """Return the per-row sync-icon URL, or ``None`` for non-run rows. +def _sync_rollup_props(node: TreeNode) -> dict[str, str] | None: + """Return the single rollup-icon props for a run row, or None for non-run rows. - Run rows get one of the two ``/assets/sync_*.svg`` URLs depending on - the derived run rollup: a ``cleared`` run (staging copy cleaned, data - on NAS only) gets ``sync_cloud.svg``; a ``syncing`` / ``synced`` run - still has data on disk and gets ``sync_local.svg``. Equipment / - project rows render unchanged. + Run rows are summarised by a single colour-coded rollup icon derived + from the run's discriminator (``node.sync_status``) via the two-icon + presentation map: ``sync_nas.svg`` (green) when fully backed up, + ``sync_local.svg`` (blue) while local files remain, plus red/amber + problem/held variants with a badge. Equipment / project rows render + no sync icon. """ if node.kind not in _RUN_KINDS: return None - if node.sync_status == RunSyncState.CLEARED.value: - return SYNC_ICON_CLOUD_URL - return SYNC_ICON_LOCAL_URL + return sync_rollup_icon_props(file_sync_view(node.sync_status)) def to_nicegui_nodes(nodes: Iterable[TreeNode]) -> list[dict[str, Any]]: @@ -291,18 +300,14 @@ def to_nicegui_nodes(nodes: Iterable[TreeNode]) -> list[dict[str, Any]]: "type_icon": type_icon, "type_color": type_color, } - icon_url = _sync_icon_url(node) - if icon_url is not None: - payload["sync_icon"] = icon_url - payload["sync_status"] = node.sync_status or "" - # Friendly hover tooltip mirroring the icon's meaning: the cloud - # (CLEARED) reads "on NAS only", the local-disk icon reads - # "data on local disk" (Phase 5 sync tooltips). - payload["sync_title"] = ( - "Cleared -- data on NAS only" - if node.sync_status == RunSyncState.CLEARED.value - else "Data on local disk" - ) + rollup = _sync_rollup_props(node) + if rollup is not None: + payload["sync_icon"] = rollup["svg"] + payload["sync_bg"] = rollup["bg_var"] + payload["sync_badge"] = rollup["badge"] + payload["sync_badge_bg"] = rollup.get("badge_bg", "--color-sync-problem") + payload["sync_title"] = rollup["tooltip"] + payload["sync_status"] = file_sync_view(node.sync_status).value out.append(payload) return out @@ -342,10 +347,24 @@ def _tree_header_slot(*, tree_id: int, listener_id: str) -> str: '
' '" - '" + '" + '" + "{{ props.node.sync_badge }}" + "" " None: def _render_sync_legend() -> None: # pragma: no cover -- NiceGUI render, driven by e2e """Render the Files-header sync-status legend ("?") popover. - Lists each sync state's icon + meaning, sourced from - :func:`sync_status_icon.sync_legend_entries` (which reads ``_STATUS_TO_PROPS``, - the single source of truth) so the legend can't drift from the icons the - file list / metadata pane actually render. + Lists each visible sync view as a real two-icon (local + NAS) swatch + + its meaning, sourced from + :func:`sync_status_icon.sync_legend_entries` so the legend renders the + same icon pairs the file list / metadata pane actually draw and can't + drift from them. """ try: from nicegui import ui except Exception: return - from exlab_wizard.ui.components.sync_status_icon import sync_legend_entries + from exlab_wizard.ui.components.sync_status_icon import ( + FileSyncView, + sync_legend_entries, + sync_pair_icons, + ) with ( ui.button(icon="help_outline") @@ -615,9 +620,7 @@ def _render_sync_legend() -> None: # pragma: no cover -- NiceGUI render, driven "gap: var(--sp-2, 0.5rem); padding: var(--sp-1) var(--sp-3); flex-wrap: nowrap;" ) ): - ui.icon(entry["icon_name"]).style( - f"color: var({entry['color_var']}); font-size: 1rem;" - ) + sync_pair_icons(FileSyncView(entry["view"])) ui.label(entry["tooltip"]).style("font-size: var(--text-sm); white-space: nowrap;") diff --git a/src/exlab_wizard/ui/theme.py b/src/exlab_wizard/ui/theme.py index b7feefe..58d01c6 100644 --- a/src/exlab_wizard/ui/theme.py +++ b/src/exlab_wizard/ui/theme.py @@ -69,6 +69,13 @@ def build_root_css() -> str: f" --color-info: {design.COLOR_INFO};\n" f" --color-warning: {design.COLOR_WARNING};\n" f" --color-danger: {design.COLOR_DANGER};\n" + # Sync-status icon tokens (two-icon presence model, 2026-05-30). + f" --color-sync-local: {design.COLOR_SYNC_LOCAL};\n" + f" --color-sync-cached: {design.COLOR_SYNC_CACHED};\n" + f" --color-sync-safe: {design.COLOR_SYNC_SAFE};\n" + f" --color-sync-absent: {design.COLOR_SYNC_ABSENT};\n" + f" --color-sync-problem: {design.COLOR_SYNC_PROBLEM};\n" + f" --color-sync-held: {design.COLOR_SYNC_HELD};\n" f" --font-display: {design.FONT_DISPLAY};\n" f" --font-body: {design.FONT_BODY};\n" f" --font-mono: {design.FONT_MONO};\n" diff --git a/tests/e2e/_test_app.py b/tests/e2e/_test_app.py index 4d593ab..371bc0f 100644 --- a/tests/e2e/_test_app.py +++ b/tests/e2e/_test_app.py @@ -173,16 +173,21 @@ def _seeded_metadata_payload(node_id: str | None, node_kind: str | None) -> dict return {} -# Default synthetic file-list feed. Operator-free per-file NAS sync -# design (2026-05-21): the rows exercise the five per-file display -# states -- a synced+kept-local file, an acquiring file, a syncing -# file, and an "On NAS" tombstone (cleared run, no local copy). Each +# Default synthetic file-list feed. Two-icon sync-presence design +# (2026-05-30): the rows exercise every per-file display state -- +# a synced+kept-local file, an acquiring file, a syncing file, an +# "On NAS" tombstone (cleared run, no local copy), a "missing" lost +# file (tracked, gone locally, never verified), an upload-failed +# file (problem/red NAS), and a blocked file (held/amber NAS). Each # tuple is (name, size|None, sync_status, keep_local, tombstone). _DEFAULT_FEED_ROWS: list[tuple[str, int | None, str | None, bool, bool]] = [ ("scan.tif", 1024, "synced", True, False), ("metadata.json", 256, "acquiring", False, False), ("frames.raw", 4096, "syncing", False, False), ("archived.tif", None, "on_nas", False, True), + ("lost.tif", None, "missing", False, True), + ("stuck.raw", 2048, "upload_failed", False, False), + ("held.json", 512, "blocked", False, False), ] @@ -244,7 +249,7 @@ def build_test_app() -> FastAPI: app.state.test_state = test_state # Mount the project's ``assets/`` directory at ``/assets`` so the - # tree component's sync-icon SVGs (sync_local.svg / sync_cloud.svg) + # tree component's sync-icon SVGs (sync_local.svg / sync_nas.svg) # resolve under e2e tests. Idempotent. register_static_assets() # Inject the :root design-token block app-wide so the demo mirrors @@ -328,16 +333,39 @@ def main_index( hierarchy: dict[Any, Any] = { tree_component.EquipmentNode("TEST_EQ1", relay=False): { tree_component.ProjectNode("LIMS-001", "Demo Project"): [ - tree_component.RunNode("Run_2026-05-07", "experimental", "Demo run"), + # Two-icon sync-presence design (2026-05-30): the run + # rollup icon now comes from file_sync_view(sync_status) + # -> sync_rollup_icon_props(view). ``None`` renders NO + # icon, so each run below carries an explicit status that + # exercises a distinct rollup state: + # syncing -> LOCAL_ONLY -> sync_local.svg (blue) + # cleared -> ON_NAS -> sync_nas.svg (green) + # upload_failed -> sync_nas.svg (red, ✕) + # blocked -> sync_nas.svg (amber, !) + tree_component.RunNode( + directory_name="Run_2026-05-07", + run_kind="experimental", + label="Demo run", + sync_status="syncing", + ), tree_component.RunNode( directory_name="Run_2026-05-06", run_kind="experimental", label="Cleared run", - # Operator-free per-file NAS sync design - # (2026-05-21): the run rollup is a RunSyncState - # value; ``cleared`` drives the cloud icon. sync_status="cleared", ), + tree_component.RunNode( + directory_name="Run_2026-05-05", + run_kind="experimental", + label="Failed run", + sync_status="upload_failed", + ), + tree_component.RunNode( + directory_name="Run_2026-05-04", + run_kind="experimental", + label="Blocked run", + sync_status="blocked", + ), tree_component.RunNode("TestRun_2026-05-07", "test", "Test run"), ], }, diff --git a/tests/e2e/page_objects/main_page.py b/tests/e2e/page_objects/main_page.py index cbbb2bf..f5ba6cb 100644 --- a/tests/e2e/page_objects/main_page.py +++ b/tests/e2e/page_objects/main_page.py @@ -87,3 +87,22 @@ def footer_clear_verified(self) -> Locator: def footer_staging_segment(self) -> Locator: """Footer Staging status segment (Redesign §4.6).""" return self._page.get_by_test_id("footer-staging-segment") + + @property + def sync_local_icons(self) -> Locator: + """Run-row rollup icons resolving to the local-presence SVG. + + Two-icon sync-presence design (2026-05-30): a run whose rollup + view is LOCAL_ONLY renders ``/assets/sync_local.svg`` (blue). + """ + return self.tree.locator('img[src="/assets/sync_local.svg"]') + + @property + def sync_nas_icons(self) -> Locator: + """Run-row rollup icons resolving to the NAS-presence SVG. + + A run whose rollup view is ON_NAS / SYNCED / UPLOAD_FAILED / + BLOCKED renders ``/assets/sync_nas.svg`` (green / red / amber by + background). + """ + return self.tree.locator('img[src="/assets/sync_nas.svg"]') diff --git a/tests/e2e/test_flow_05_browse_view_sync_icons.py b/tests/e2e/test_flow_05_browse_view_sync_icons.py index 45d407b..0dd5b72 100644 --- a/tests/e2e/test_flow_05_browse_view_sync_icons.py +++ b/tests/e2e/test_flow_05_browse_view_sync_icons.py @@ -1,13 +1,24 @@ -"""E2E flow 05b: per-run sync icons in the browse tree. +"""E2E flow 05b: per-run sync rollup icons in the browse tree. -Verifies that the project / equipment tree (Frontend §3.5) renders the -correct SVG icon to the left of each run name based on its sync status: +Two-icon sync-presence design (2026-05-30). The project / equipment +tree (Frontend §3.5) renders a single colour-coded rollup icon to the +left of each run name, derived from ``file_sync_view(sync_status)`` -> +``sync_rollup_icon_props(view)``: -* rollup other than ``cleared`` (or absent) -> ``/assets/sync_local.svg`` -* rollup ``cleared`` -> ``/assets/sync_cloud.svg`` +* ``syncing`` / ``pending`` / ``local_only`` -> LOCAL_ONLY -> ``/assets/sync_local.svg`` (blue) +* ``synced`` / ``cleared`` / ``on_nas`` -> ON_NAS/SYNCED -> ``/assets/sync_nas.svg`` (green) +* ``upload_failed`` / ``failed`` -> UPLOAD_FAILED -> ``/assets/sync_nas.svg`` (red, ✕ badge) +* ``blocked`` / ``blocked_by_validation`` -> BLOCKED -> ``/assets/sync_nas.svg`` (amber, ! badge) +* ``None`` / ``""`` / unknown -> NONE -> no icon rendered -Also asserts the static asset mount actually serves the SVGs (200 OK) -so a missing PyInstaller bundle entry would surface here. +The seeded hierarchy in ``tests/e2e/_test_app.py`` carries one run of +each visible state (plus a NONE test-run that renders nothing), so the +tree shows exactly one ``sync_local.svg`` and three ``sync_nas.svg`` +icons, one problem background, and two badges. + +Also asserts the static asset mount serves the SVGs (200 OK) so a +missing PyInstaller bundle entry would surface here. The retired cloud +asset is no longer referenced anywhere. """ from __future__ import annotations @@ -25,29 +36,34 @@ def test_flow_05_sync_icons_render_in_tree(page, server_url) -> None: tree = page.locator('[data-testid="main-tree"]') - # The seeded hierarchy in tests/e2e/_test_app.py contains: - # - Run_2026-05-07 (local; sync_status=None) -> sync_local.svg - # - Run_2026-05-06 (cleared; sync_status=cleared) -> sync_cloud.svg - # - TestRun_2026-05-07 (local; sync_status=None) -> sync_local.svg + # Seeded runs under TEST_EQ1 / LIMS-001: + # Run_2026-05-07 (syncing) -> LOCAL_ONLY -> sync_local.svg + # Run_2026-05-06 (cleared) -> ON_NAS -> sync_nas.svg (green) + # Run_2026-05-05 (upload_failed) -> UPLOAD_FAILED -> sync_nas.svg (red, ✕) + # Run_2026-05-04 (blocked) -> BLOCKED -> sync_nas.svg (amber, !) + # TestRun_2026-05-07 (None) -> NONE -> no icon local_icons = tree.locator('img[src="/assets/sync_local.svg"]') - cloud_icons = tree.locator('img[src="/assets/sync_cloud.svg"]') + nas_icons = tree.locator('img[src="/assets/sync_nas.svg"]') - # Two local runs (one experimental, one test) and one cleaned run. - assert local_icons.count() == 2, f"expected 2 sync_local icons, got {local_icons.count()}" - assert cloud_icons.count() == 1, f"expected 1 sync_cloud icon, got {cloud_icons.count()}" + # At least one local-only (blue) run and at least one NAS-presence run. + assert local_icons.count() >= 1, f"expected >= 1 sync_local icon, got {local_icons.count()}" + assert nas_icons.count() >= 1, f"expected >= 1 sync_nas icon, got {nas_icons.count()}" - # The cleaned-run icon's parent header carries the canonical sync_status - # marker on the label span (set by the default-header slot template). - cloud_header = cloud_icons.first.locator("xpath=..") - assert cloud_header.locator('span[data-sync-status="cleared"]').count() == 1, ( - "cleared run header missing data-sync-status='cleared' marker" + # The failed run carries the problem (red) background... + problem_bg = tree.locator('span[data-sync-bg="--color-sync-problem"]') + assert problem_bg.count() >= 1, ( + f"expected >= 1 problem-background rollup, got {problem_bg.count()}" ) + # ...and a corner badge (✕ for failed, ! for blocked) is rendered. + badges = tree.locator('span[data-sync-badge="true"]') + assert badges.count() >= 1, f"expected >= 1 sync badge, got {badges.count()}" + def test_flow_05_sync_icons_static_assets_serve_200(server_url) -> None: - """The ``/assets`` mount serves both SVGs as 200 OK.""" + """The ``/assets`` mount serves both presence SVGs as 200 OK.""" - for url in (f"{server_url}/assets/sync_local.svg", f"{server_url}/assets/sync_cloud.svg"): + for url in (f"{server_url}/assets/sync_local.svg", f"{server_url}/assets/sync_nas.svg"): response = httpx.get(url, timeout=5.0) assert response.status_code == 200, f"{url} returned {response.status_code}" body = response.text diff --git a/tests/e2e/test_flow_28_selection_search_density.py b/tests/e2e/test_flow_28_selection_search_density.py index 5d80c71..24e15bf 100644 --- a/tests/e2e/test_flow_28_selection_search_density.py +++ b/tests/e2e/test_flow_28_selection_search_density.py @@ -60,8 +60,13 @@ def test_flow_28_search_filters_tree_and_shows_count(page, server_url) -> None: _goto(page, f"{server_url}/main?q=Demo") count = page.locator('[data-testid="main-search-count"]') count.wait_for(state="visible", timeout=10_000) - # The seeded query surfaced exactly the Demo project + its 3 runs. - assert count.inner_text().strip() == "4 results" + # The seeded query surfaced the Demo project + its run nodes. The + # two-icon sync-presence design (2026-05-30) added the failed/blocked + # runs to ``_test_app``'s hierarchy so the tree rollup states are + # covered, so "Demo" now matches the project plus five runs + # (Run_2026-05-07/06/05/04 + TestRun_2026-05-07). Keep in lockstep + # with that seeded hierarchy. + assert count.inner_text().strip() == "6 results" def test_flow_28_search_no_matches_state(page, server_url) -> None: @@ -87,8 +92,11 @@ def test_flow_28_sync_legend_popover_lists_states(page, server_url) -> None: legend.click() menu = page.locator('[data-testid="files-legend-menu"]') menu.wait_for(state="visible", timeout=5_000) - # A couple of known meanings from _STATUS_TO_PROPS are listed. - assert "Synced and verified at NAS" in menu.inner_text() + # A couple of known meanings from the two-icon FileSyncView legend + # (sync_legend_entries) are listed. + text = menu.inner_text() + assert "Fully backed up on the NAS" in text + assert "Sync held by a validation finding" in text def test_flow_28_selected_tree_node_carries_selected_class(page, server_url) -> None: diff --git a/tests/unit/api/test_browse_sync_view.py b/tests/unit/api/test_browse_sync_view.py new file mode 100644 index 0000000..dbe18bb --- /dev/null +++ b/tests/unit/api/test_browse_sync_view.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from exlab_wizard.api.routers import browse + + +class _Rec: + def __init__(self, verified: bool): + self.verified_at = "2026-05-30T00:00:00Z" if verified else None + self.keep_local = False + + +def test_no_record_on_disk_is_acquiring(): + assert browse._file_state_from_record(None, on_disk=True) == "acquiring" + + +def test_no_record_absent_is_none(): + assert browse._file_state_from_record(None, on_disk=False) is None + + +def test_verified_on_disk_is_synced(): + assert browse._file_state_from_record(_Rec(True), on_disk=True) == "synced" + + +def test_unverified_on_disk_is_syncing(): + assert browse._file_state_from_record(_Rec(False), on_disk=True) == "syncing" + + +def test_verified_absent_is_on_nas(): + assert browse._file_state_from_record(_Rec(True), on_disk=False) == "on_nas" + + +def test_unverified_absent_is_missing(): + # Previously None (silently dropped); now surfaced as a lost file. + assert browse._file_state_from_record(_Rec(False), on_disk=False) == "missing" + + +def test_run_failed_makes_unverified_on_disk_upload_failed(): + assert ( + browse._file_state_from_record(_Rec(False), on_disk=True, run_failed=True) + == "upload_failed" + ) + + +def test_run_blocked_makes_unverified_on_disk_blocked(): + assert browse._file_state_from_record(_Rec(False), on_disk=True, run_blocked=True) == "blocked" + + +def test_run_failed_does_not_override_synced(): + assert browse._file_state_from_record(_Rec(True), on_disk=True, run_failed=True) == "synced" diff --git a/tests/unit/ui/test_components.py b/tests/unit/ui/test_components.py index 0affde3..0918c4d 100644 --- a/tests/unit/ui/test_components.py +++ b/tests/unit/ui/test_components.py @@ -9,8 +9,6 @@ from __future__ import annotations -import pytest - from exlab_wizard.constants import TreeProjectStatus from exlab_wizard.ui.components import ( bandwidth_schedule_editor, @@ -97,99 +95,115 @@ def test_override_badge_inactive_uses_muted() -> None: # --------------------------------------------------------------------------- -def test_sync_status_pending_uses_muted() -> None: - props = sync_status_icon.sync_status_props("pending") - assert props["color_var"] == "--color-muted" - assert props["icon_name"] == "schedule" - +def test_file_sync_view_maps_backend_discriminators() -> None: + """The backend discriminator string collapses to the right view (two-icon + sync-presence design, 2026-05-30).""" -def test_sync_status_synced_uses_success() -> None: - props = sync_status_icon.sync_status_props("synced") - assert props["color_var"] == "--color-success" + fsv = sync_status_icon.file_sync_view + FileSyncView = sync_status_icon.FileSyncView + assert fsv("synced") is FileSyncView.SYNCED + assert fsv("syncing") is FileSyncView.LOCAL_ONLY + assert fsv("acquiring") is FileSyncView.LOCAL_ONLY + assert fsv("on_nas") is FileSyncView.ON_NAS + assert fsv("cleaned") is FileSyncView.ON_NAS + assert fsv("cleared") is FileSyncView.ON_NAS + assert fsv("failed") is FileSyncView.UPLOAD_FAILED + assert fsv("upload_failed") is FileSyncView.UPLOAD_FAILED + assert fsv("blocked") is FileSyncView.BLOCKED + assert fsv("blocked_by_validation") is FileSyncView.BLOCKED + assert fsv("missing") is FileSyncView.MISSING -def test_sync_status_failed_uses_danger() -> None: - props = sync_status_icon.sync_status_props("failed") - assert props["color_var"] == "--color-danger" +def test_file_sync_view_unknown_and_none_map_to_none_view() -> None: + assert sync_status_icon.file_sync_view("invalid") is sync_status_icon.FileSyncView.NONE + assert sync_status_icon.file_sync_view(None) is sync_status_icon.FileSyncView.NONE + assert sync_status_icon.file_sync_view("") is sync_status_icon.FileSyncView.NONE -def test_sync_status_blocked_uses_warning() -> None: - """Blocked-by-validation maps to warning per Frontend §2.1.4.""" +def test_sync_pair_props_local_only_blue_local_gray_nas() -> None: + """LOCAL_ONLY: present locally (blue), not yet on the NAS (faded gray).""" - props = sync_status_icon.sync_status_props("blocked_by_validation") - assert props["color_var"] == "--color-warning" + p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.LOCAL_ONLY) + assert p["local"]["bg_var"] == "--color-sync-local" + assert p["nas"]["bg_var"] == "--color-sync-absent" + assert p["nas"]["faded"] is True -def test_sync_status_override_uses_info() -> None: - props = sync_status_icon.sync_status_props("override_active") - assert props["color_var"] == "--color-info" +def test_sync_pair_props_synced_cached_local_safe_nas() -> None: + p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.SYNCED) + assert p["local"]["bg_var"] == "--color-sync-cached" + assert p["nas"]["bg_var"] == "--color-sync-safe" -def test_sync_status_cleaned_uses_success_with_cloud_icon() -> None: - """Cleaned status (post-cleanup) reuses the success color with a cloud glyph.""" +def test_sync_pair_props_on_nas_gray_local_safe_nas() -> None: + p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.ON_NAS) + assert p["local"]["bg_var"] == "--color-sync-absent" + assert p["nas"]["bg_var"] == "--color-sync-safe" - props = sync_status_icon.sync_status_props("cleaned") - assert props["color_var"] == "--color-success" - assert props["icon_name"] == "cloud_done" +def test_sync_pair_props_upload_failed_red_nas_with_badge() -> None: + """A failed upload paints the NAS cell red with an ``✕`` badge.""" -def test_sync_status_acquiring_uses_muted() -> None: - """``acquiring`` (new file, still settling) uses the muted token.""" + p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.UPLOAD_FAILED) + assert p["local"]["bg_var"] == "--color-sync-local" + assert p["nas"]["bg_var"] == "--color-sync-problem" + assert p["nas"]["badge"] == "✕" - props = sync_status_icon.sync_status_props("acquiring") - assert props["color_var"] == "--color-muted" - assert props["status"] == "acquiring" - assert props["icon_name"] +def test_sync_pair_props_blocked_amber_nas_with_badge() -> None: + """A held (blocked-by-validation) upload paints the NAS cell amber + ``!``.""" -def test_sync_status_syncing_uses_info() -> None: - """``syncing`` (settled, transferring) uses the info token.""" + p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.BLOCKED) + assert p["nas"]["bg_var"] == "--color-sync-held" + assert p["nas"]["badge"] == "!" - props = sync_status_icon.sync_status_props("syncing") - assert props["color_var"] == "--color-info" - assert props["status"] == "syncing" - assert props["icon_name"] +def test_sync_pair_props_missing_red_both_with_badges() -> None: + p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.MISSING) + assert p["local"]["bg_var"] == "--color-sync-problem" + assert p["nas"]["bg_var"] == "--color-sync-problem" + assert p["local"]["badge"] == "✕" and p["nas"]["badge"] == "✕" -def test_sync_status_on_nas_uses_muted_cloud() -> None: - """``on_nas`` (tombstone, local copy cleared) uses a muted cloud glyph.""" - props = sync_status_icon.sync_status_props("on_nas") - assert props["color_var"] == "--color-muted" - assert props["status"] == "on_nas" - assert props["icon_name"] == "cloud" +def test_sync_pair_props_none_view_returns_none() -> None: + """The NONE view renders nothing (no pair props).""" + assert sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.NONE) is None -def test_sync_status_retrying_with_counter() -> None: - """Retry counter renders as ``(N/M)`` (Frontend §10.5.1).""" - props = sync_status_icon.sync_status_props("retrying", retry_n=2, retry_m=5) - assert props["color_var"] == "--color-info" - assert props["retry_label"] == "(2/5)" +def test_sync_rollup_icon_props_safe_local_problem() -> None: + """The single rollup icon: green NAS for safe, blue local while not synced, + red NAS + badge for a problem.""" + safe = sync_status_icon.sync_rollup_icon_props(sync_status_icon.FileSyncView.SYNCED) + assert safe["svg"] == sync_status_icon.SYNC_NAS_SVG + assert safe["bg_var"] == "--color-sync-safe" + local = sync_status_icon.sync_rollup_icon_props(sync_status_icon.FileSyncView.LOCAL_ONLY) + assert local["svg"] == sync_status_icon.SYNC_LOCAL_SVG + assert local["bg_var"] == "--color-sync-local" + problem = sync_status_icon.sync_rollup_icon_props(sync_status_icon.FileSyncView.UPLOAD_FAILED) + assert problem["bg_var"] == "--color-sync-problem" + assert problem["badge"] == "✕" -def test_sync_status_unknown_raises() -> None: - """Unknown status values raise.""" - with pytest.raises(ValueError): - sync_status_icon.sync_status_props("invalid") +def test_sync_rollup_icon_props_none_view_returns_none() -> None: + assert sync_status_icon.sync_rollup_icon_props(sync_status_icon.FileSyncView.NONE) is None -def test_sync_legend_entries_cover_every_state() -> None: - """The legend lists one row per known state, each with icon + meaning, - sourced from _STATUS_TO_PROPS (single source of truth, Phase 5).""" +def test_sync_legend_entries_cover_every_visible_view() -> None: + """The legend lists one row per visible (non-NONE) view, each carrying the + view value the swatch renderer rehydrates + a human tooltip.""" entries = sync_status_icon.sync_legend_entries() - # One row per documented state; each carries the keys the legend renders. - assert len(entries) == len(sync_status_icon._STATUS_TO_PROPS) + assert entries + views = {e["view"] for e in entries} + assert sync_status_icon.FileSyncView.NONE.value not in views for entry in entries: - assert entry["status"] - assert entry["icon_name"] - assert entry["color_var"].startswith("--color-") + # ``view`` rehydrates to a real (non-NONE) FileSyncView. + assert ( + sync_status_icon.FileSyncView(entry["view"]) is not sync_status_icon.FileSyncView.NONE + ) assert entry["tooltip"] - # The mapping agrees with sync_status_props for a known state. - synced = next(e for e in entries if e["status"] == "synced") - assert synced["icon_name"] == "check_circle" # --------------------------------------------------------------------------- @@ -817,12 +831,9 @@ def test_tree_run_node_propagates_sync_status() -> None: assert nodes[0].children[0].children[0].sync_status == "cleaned" -def test_to_nicegui_nodes_cleared_run_uses_cloud_icon() -> None: - """A ``cleared`` run row carries the cloud-icon URL and its sync_status. - - Operator-free per-file NAS sync design (2026-05-21): the run rollup - is a :class:`RunSyncState` value; the cloud icon keys off ``cleared``. - """ +def test_to_nicegui_nodes_cleared_run_uses_nas_icon() -> None: + """A ``cleared`` run rolls up (via file_sync_view) to ON_NAS: the green NAS + rollup icon (two-icon sync-presence design, 2026-05-30).""" equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01") project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3") @@ -838,12 +849,16 @@ def test_to_nicegui_nodes_cleared_run_uses_cloud_icon() -> None: ) ) run_dict = payload[0]["children"][0]["children"][0] - assert run_dict["sync_icon"] == tree.SYNC_ICON_CLOUD_URL - assert run_dict["sync_status"] == "cleared" + assert run_dict["sync_icon"] == tree.SYNC_ICON_NAS_URL + assert run_dict["sync_bg"] == "--color-sync-safe" + assert run_dict["sync_status"] == sync_status_icon.FileSyncView.ON_NAS.value def test_to_nicegui_nodes_local_run_uses_local_icon() -> None: - """Any non-``cleaned`` sync status (or unset) maps to the local-icon URL.""" + """A still-local run (pending / syncing) gets the blue local rollup icon. + + An unrecognised / unset status maps to NONE -> no sync icon is emitted. + """ equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01") project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3") @@ -865,7 +880,31 @@ def test_to_nicegui_nodes_local_run_uses_local_icon() -> None: ) run_dicts = payload[0]["children"][0]["children"] assert run_dicts[0]["sync_icon"] == tree.SYNC_ICON_LOCAL_URL - assert run_dicts[1]["sync_icon"] == tree.SYNC_ICON_LOCAL_URL + assert run_dicts[0]["sync_bg"] == "--color-sync-local" + # A None / unknown status -> NONE view -> rollup props is None -> no icon. + assert "sync_icon" not in run_dicts[1] + + +def test_to_nicegui_nodes_failed_run_carries_problem_badge() -> None: + """A failed run rolls up to the red NAS rollup icon + ``✕`` badge.""" + + equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01") + project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3") + run = tree.RunNode( + directory_name="Run_2026-05-07", + run_kind="experimental", + sync_status="failed", + ) + payload = tree.to_nicegui_nodes( + tree.build_nodes( + hierarchy={equipment: {project: [run]}}, + filters=tree.TreeFilters(), + ) + ) + run_dict = payload[0]["children"][0]["children"][0] + assert run_dict["sync_icon"] == tree.SYNC_ICON_NAS_URL + assert run_dict["sync_bg"] == "--color-sync-problem" + assert run_dict["sync_badge"] == "✕" def test_to_nicegui_nodes_equipment_and_project_have_no_sync_icon() -> None: @@ -873,7 +912,9 @@ def test_to_nicegui_nodes_equipment_and_project_have_no_sync_icon() -> None: equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01") project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3") - run = tree.RunNode(directory_name="Run_2026-05-07", run_kind="experimental") + run = tree.RunNode( + directory_name="Run_2026-05-07", run_kind="experimental", sync_status="synced" + ) payload = tree.to_nicegui_nodes( tree.build_nodes( hierarchy={equipment: {project: [run]}}, @@ -887,8 +928,7 @@ def test_to_nicegui_nodes_equipment_and_project_have_no_sync_icon() -> None: def test_to_nicegui_nodes_carries_sync_title() -> None: """Run rows carry a friendly ``sync_title`` hover tooltip mirroring the - icon: cleared -> "on NAS only", anything else -> "data on local disk" - (Phase 5 sync tooltips).""" + rollup icon's meaning (two-icon sync-presence design, 2026-05-30).""" equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01") project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3") @@ -896,7 +936,7 @@ def test_to_nicegui_nodes_carries_sync_title() -> None: directory_name="Run_2026-05-06", run_kind="experimental", sync_status="cleared" ) local = tree.RunNode( - directory_name="Run_2026-05-07", run_kind="experimental", sync_status="synced" + directory_name="Run_2026-05-07", run_kind="experimental", sync_status="syncing" ) payload = tree.to_nicegui_nodes( tree.build_nodes( @@ -905,7 +945,9 @@ def test_to_nicegui_nodes_carries_sync_title() -> None: ) ) run_dicts = payload[0]["children"][0]["children"] + # cleared -> ON_NAS rollup: "Fully backed up on the NAS". assert "NAS" in run_dicts[0]["sync_title"] + # syncing -> LOCAL_ONLY rollup: "Not fully synced -- local files remain". assert "local" in run_dicts[1]["sync_title"].lower() @@ -1037,7 +1079,7 @@ def test_factories_smoke_outside_nicegui() -> None: assert mode_badge.mode_badge("experimental") is not None assert test_run_badge.test_run_badge() is not None assert override_badge.override_badge(active=True) is not None - assert sync_status_icon.sync_status_icon("synced") is not None + assert sync_status_icon.sync_pair_icons(sync_status_icon.file_sync_view("synced")) is not None # Component factories that return rich payloads: assert session_progress.session_progress(active_phase=None) is not None diff --git a/tests/unit/ui/test_factories_smoke.py b/tests/unit/ui/test_factories_smoke.py index 136309b..59bdd98 100644 --- a/tests/unit/ui/test_factories_smoke.py +++ b/tests/unit/ui/test_factories_smoke.py @@ -79,18 +79,27 @@ def test_smoke_override_badge_renders_with_callback() -> None: def test_smoke_sync_status_icon_renders_each_state() -> None: + # Two-icon sync-presence (2026-05-30): each file renders a local+NAS pair; + # runs/folders render a single rollup icon. Both no-op safely per view. for status in ( "pending", "synced", "failed", "blocked_by_validation", - "override_active", + "on_nas", + "missing", ): + view = sync_status_icon.file_sync_view(status) with _slot(): - out = sync_status_icon.sync_status_icon(status) + out = sync_status_icon.sync_pair_icons(view) assert out is not None + with _slot(): + out = sync_status_icon.sync_rollup_icon(view) + assert out is not None + # The NONE view (unknown / None status) still renders a placeholder span. + none_view = sync_status_icon.file_sync_view("nope") with _slot(): - out = sync_status_icon.sync_status_icon("retrying", retry_n=1, retry_m=3) + out = sync_status_icon.sync_pair_icons(none_view) assert out is not None diff --git a/tests/unit/ui/test_main_page_legend.py b/tests/unit/ui/test_main_page_legend.py new file mode 100644 index 0000000..3354a16 --- /dev/null +++ b/tests/unit/ui/test_main_page_legend.py @@ -0,0 +1,29 @@ +"""Unit tests for the main-page sync-status legend popover. + +The legend lists each sync view as a two-icon (local + NAS) swatch + its +meaning so operators can decode the per-file / per-run sync icons +(two-icon sync-presence design, 2026-05-30). +""" + +from __future__ import annotations + +from exlab_wizard.ui.components.sync_status_icon import FileSyncView, sync_legend_entries + + +def test_legend_entries_have_view_and_tooltip() -> None: + """Each legend entry exposes the view value + human tooltip the popover needs.""" + entries = sync_legend_entries() + assert entries, "legend must list at least one sync state" + for entry in entries: + # ``view`` must be a real FileSyncView value the renderer can rehydrate. + assert FileSyncView(entry["view"]) is not FileSyncView.NONE + assert entry["tooltip"], "each legend entry needs a tooltip" + + +def test_legend_renders_without_error() -> None: + """The legend renderer tolerates a no-NiceGUI context (returns cleanly).""" + from exlab_wizard.ui.pages.main import _render_sync_legend + + # Outside a NiceGUI app context the helper must no-op without raising. + _render_sync_legend() + return None diff --git a/tests/unit/ui/test_mount.py b/tests/unit/ui/test_mount.py index ad5836a..176616c 100644 --- a/tests/unit/ui/test_mount.py +++ b/tests/unit/ui/test_mount.py @@ -1495,8 +1495,9 @@ def test_build_selected_file_folder_delegates_to_aggregate( assert payload["name"] == "Runs" assert payload["path"] == "/d/EQ1/Runs" assert payload["item_count"] == 3 - # Worst-of rollup: a single failed child dominates. - assert payload["rollup"] == "failed" + # Worst-of rollup: a single failed child dominates. The two-icon view + # model maps the "failed" discriminator to the "upload_failed" view value. + assert payload["rollup"] == "upload_failed" def test_build_selected_folder_degrades_on_scan_failure( diff --git a/tests/unit/ui/test_sync_rollup.py b/tests/unit/ui/test_sync_rollup.py index a11aae1..d90a40c 100644 --- a/tests/unit/ui/test_sync_rollup.py +++ b/tests/unit/ui/test_sync_rollup.py @@ -1,70 +1,36 @@ -"""Unit tests for the folder-level sync-status rollup (Redesign §4.6).""" - from __future__ import annotations -from exlab_wizard.constants import SyncStatus from exlab_wizard.ui.components.sync_rollup import sync_rollup -FAILED = SyncStatus.FAILED.value -BLOCKED = SyncStatus.BLOCKED_BY_VALIDATION.value -PENDING = SyncStatus.PENDING.value -SYNCED = SyncStatus.SYNCED.value -CLEANED = SyncStatus.CLEANED.value - -def test_empty_input_returns_none() -> None: +def test_empty_is_none(): assert sync_rollup([]) is None -def test_all_none_returns_none() -> None: - assert sync_rollup([None, None]) is None - - -def test_unknown_values_ignored() -> None: - assert sync_rollup(["bogus", None]) is None - - -def test_single_status_rolls_up_to_itself() -> None: - assert sync_rollup([SYNCED]) == SYNCED - - -def test_failed_dominates_everything() -> None: - assert sync_rollup([SYNCED, CLEANED, PENDING, BLOCKED, FAILED]) == FAILED - - -def test_blocked_beats_pending_synced_cleaned() -> None: - assert sync_rollup([CLEANED, SYNCED, PENDING, BLOCKED]) == BLOCKED - +def test_only_none_is_none(): + assert sync_rollup([None, "", "bogus"]) is None -def test_pending_beats_synced_and_cleaned() -> None: - assert sync_rollup([CLEANED, SYNCED, PENDING]) == PENDING +def test_all_synced(): + assert sync_rollup(["synced", "synced"]) == "synced" -def test_synced_beats_cleaned() -> None: - assert sync_rollup([CLEANED, SYNCED]) == SYNCED +def test_on_nas_is_calmest(): + # on_nas/cleaned/cleared roll up to on_nas only when nothing louder present + assert sync_rollup(["cleaned", "on_nas"]) == "on_nas" -def test_cleaned_is_lowest() -> None: - assert sync_rollup([CLEANED, CLEANED]) == CLEANED +def test_local_only_outranks_synced(): + assert sync_rollup(["synced", "acquiring"]) == "local_only" -def test_none_and_unknown_excluded_but_known_survives() -> None: - assert sync_rollup([None, "bogus", SYNCED, None]) == SYNCED +def test_blocked_outranks_local_only(): + assert sync_rollup(["acquiring", "blocked"]) == "blocked" -def test_full_severity_order() -> None: - """failed > blocked > pending > synced > cleaned: each prefix's rollup is - its first (highest-severity) element.""" - order = [FAILED, BLOCKED, PENDING, SYNCED, CLEANED] - for i in range(len(order)): - assert sync_rollup(order[i:]) == order[i] +def test_upload_failed_outranks_blocked(): + assert sync_rollup(["blocked", "failed"]) == "upload_failed" -def test_accepts_syncstatus_enum_members_directly() -> None: - """SyncStatus is a StrEnum, so passing members (not .value strings) works. - Guards the contract that a future caller can feed enum instances from - ``FileListEntry.sync_status`` without first calling ``.value``. - """ - assert sync_rollup([SyncStatus.SYNCED, SyncStatus.FAILED]) == FAILED - assert sync_rollup([SyncStatus.CLEANED, "failed"]) == FAILED +def test_missing_is_worst(): + assert sync_rollup(["failed", "missing", "synced"]) == "missing" diff --git a/tests/unit/ui/test_sync_status_icon.py b/tests/unit/ui/test_sync_status_icon.py index 1575775..bf0a515 100644 --- a/tests/unit/ui/test_sync_status_icon.py +++ b/tests/unit/ui/test_sync_status_icon.py @@ -1,51 +1,103 @@ -"""Unit tests for sync_status_icon tolerant mode (Redesign §4.5, OQ-5).""" - from __future__ import annotations import pytest -from exlab_wizard.constants import SyncStatus -from exlab_wizard.ui.components.sync_status_icon import sync_status_props +from exlab_wizard.ui.components.sync_status_icon import ( + FileSyncView, + file_sync_view, + sync_pair_props, + sync_rollup_icon_props, +) + +SYNC_LOCAL = "/assets/sync_local.svg" +SYNC_NAS = "/assets/sync_nas.svg" + + +@pytest.mark.parametrize( + "status, expected", + [ + ("synced", FileSyncView.SYNCED), + ("acquiring", FileSyncView.LOCAL_ONLY), + ("syncing", FileSyncView.LOCAL_ONLY), + ("on_nas", FileSyncView.ON_NAS), + ("cleaned", FileSyncView.ON_NAS), + ("cleared", FileSyncView.ON_NAS), + ("upload_failed", FileSyncView.UPLOAD_FAILED), + ("failed", FileSyncView.UPLOAD_FAILED), + ("blocked", FileSyncView.BLOCKED), + ("blocked_by_validation", FileSyncView.BLOCKED), + ("missing", FileSyncView.MISSING), + (None, FileSyncView.NONE), + ("", FileSyncView.NONE), + ("bogus", FileSyncView.NONE), + ], +) +def test_file_sync_view_mapping(status, expected): + assert file_sync_view(status) is expected + + +def test_pair_props_local_only_blue_local_gray_nas(): + p = sync_pair_props(FileSyncView.LOCAL_ONLY) + assert p["local"]["svg"] == SYNC_LOCAL + assert p["local"]["bg_var"] == "--color-sync-local" + assert p["nas"]["svg"] == SYNC_NAS + assert p["nas"]["bg_var"] == "--color-sync-absent" + assert p["nas"]["faded"] is True + + +def test_pair_props_synced_faded_blue_local_green_nas(): + p = sync_pair_props(FileSyncView.SYNCED) + assert p["local"]["bg_var"] == "--color-sync-cached" + assert p["nas"]["bg_var"] == "--color-sync-safe" + + +def test_pair_props_on_nas_gray_local_green_nas(): + p = sync_pair_props(FileSyncView.ON_NAS) + assert p["local"]["bg_var"] == "--color-sync-absent" + assert p["nas"]["bg_var"] == "--color-sync-safe" -def test_strict_default_still_raises_on_unknown() -> None: - """The original contract is preserved: strict (default) raises.""" - with pytest.raises(ValueError, match="unknown sync status"): - sync_status_props("bogus") +def test_pair_props_upload_failed_red_nas_badge(): + p = sync_pair_props(FileSyncView.UPLOAD_FAILED) + assert p["local"]["bg_var"] == "--color-sync-local" + assert p["nas"]["bg_var"] == "--color-sync-problem" + assert p["nas"]["badge"] == "✕" -def test_strict_explicit_raises_on_unknown() -> None: - with pytest.raises(ValueError, match="unknown sync status"): - sync_status_props("bogus", strict=True) +def test_pair_props_blocked_amber_nas_badge(): + p = sync_pair_props(FileSyncView.BLOCKED) + assert p["nas"]["bg_var"] == "--color-sync-held" + assert p["nas"]["badge"] == "!" -def test_tolerant_mode_returns_neutral_for_unknown() -> None: - """strict=False yields neutral props (muted dash) instead of raising.""" - props = sync_status_props("bogus", strict=False) - assert props["icon_name"] == "" - assert props["color_var"] == "--color-muted" - assert props["tooltip"] == "" - assert props["retry_label"] == "" - assert props["status"] == "bogus" +def test_pair_props_missing_red_both_badges(): + p = sync_pair_props(FileSyncView.MISSING) + assert p["local"]["bg_var"] == "--color-sync-problem" + assert p["nas"]["bg_var"] == "--color-sync-problem" + assert p["local"]["badge"] == "✕" and p["nas"]["badge"] == "✕" -def test_tolerant_mode_handles_none() -> None: - """strict=False accepts None (optional per-file / per-folder status).""" - props = sync_status_props(None, strict=False) - assert props["icon_name"] == "" - assert props["color_var"] == "--color-muted" - assert props["status"] == "" +def test_pair_props_none_returns_none(): + assert sync_pair_props(FileSyncView.NONE) is None -def test_tolerant_mode_still_returns_real_props_for_known() -> None: - """A recognised status is unaffected by strict=False.""" - props = sync_status_props(SyncStatus.SYNCED, strict=False) - assert props["icon_name"] - assert props["status"] == SyncStatus.SYNCED.value +@pytest.mark.parametrize( + "view, svg, bg, badge", + [ + (FileSyncView.SYNCED, SYNC_NAS, "--color-sync-safe", ""), + (FileSyncView.ON_NAS, SYNC_NAS, "--color-sync-safe", ""), + (FileSyncView.LOCAL_ONLY, SYNC_LOCAL, "--color-sync-local", ""), + (FileSyncView.BLOCKED, SYNC_NAS, "--color-sync-held", "!"), + (FileSyncView.UPLOAD_FAILED, SYNC_NAS, "--color-sync-problem", "✕"), + (FileSyncView.MISSING, SYNC_NAS, "--color-sync-problem", "✕"), + ], +) +def test_rollup_icon_props(view, svg, bg, badge): + p = sync_rollup_icon_props(view) + assert p["svg"] == svg + assert p["bg_var"] == bg + assert p["badge"] == badge -def test_neutral_and_normal_props_share_key_shape() -> None: - """Both return paths expose the same keys so callers treat them alike.""" - normal = sync_status_props(SyncStatus.SYNCED) - neutral = sync_status_props(None, strict=False) - assert set(normal) == set(neutral) +def test_rollup_icon_props_none(): + assert sync_rollup_icon_props(FileSyncView.NONE) is None diff --git a/tests/unit/ui/test_theme.py b/tests/unit/ui/test_theme.py index e511f9e..427bd71 100644 --- a/tests/unit/ui/test_theme.py +++ b/tests/unit/ui/test_theme.py @@ -119,6 +119,21 @@ def test_root_css_contains_semantic_aliases() -> None: assert "--color-danger" in css +def test_root_css_emits_sync_tokens() -> None: + """The two-icon sync-presence tokens are emitted into the :root block.""" + + css = theme.build_root_css() + for var in ( + "--color-sync-local", + "--color-sync-cached", + "--color-sync-safe", + "--color-sync-absent", + "--color-sync-problem", + "--color-sync-held", + ): + assert var in css, f"{var} missing from :root block" + + def test_root_css_contains_typography_tokens() -> None: css = theme.build_root_css() assert "IBM Plex Sans" in css