Skip to content

fix(note-arrays): size send paths to real dimensions + harden tests [gate for #1185]#1296

Merged
jeffredodd merged 2 commits into
nextfrom
verify/note-arrays-1185
Jun 28, 2026
Merged

fix(note-arrays): size send paths to real dimensions + harden tests [gate for #1185]#1296
jeffredodd merged 2 commits into
nextfrom
verify/note-arrays-1185

Conversation

@jeffredodd

@jeffredodd jeffredodd commented Jun 27, 2026

Copy link
Copy Markdown
Member

Why this PR

This is the human-in-the-loop gate for #1185 (Note Arrays). It targets next, so merging it updates #1185. Goal: when this merges, #1185 is actually 100% correct for arrays + multi-board, with tests you can trust.

I rebuilt the next (04c4f08b) dev stack from scratch (with the new mock Cloud board on :19200) and verified the feature at runtime — not just by reading tests. That surfaced a ship-blocker the existing tests hid.

🔴 Blocker fixed: note-array boards 500 on every send path

get_dimensions() only knows flagship/note and raises ValueError on note_array. Five send paths called it. Reproduced live:

POST /templates/render/live  (note-array board)  ->  HTTP 500
  File "src/api_server.py", line 7419, in render_template_live
    dims = get_dimensions(device_type)
  ValueError: Unknown device type: note_array. ... use resolve_dimensions().

A user cannot send to a note-array board via the live editor, /pages/{id}/send, /settings/active-page, or /displays/{type}/send. The send-endpoint tests patch("…get_dimensions") and hardcode rows=6, cols=22, so they could never catch it.

Fix: all five send sites use resolve_dimensions(device_type, notes_wide, notes_tall). After the fix the same live send returns 200 and lands a 3×60 grid on the mock.

What's in this PR (all verified green)

Change File
5 send paths size to real note-array dims (was 500) src/api_server.py
Guard from_dict against a JSON-null note_array_token (None.strip() crash) src/devices.py
New regression test: live send to note-array board, resolver NOT mocked, asserts 3×60 grid tests/test_api_extended.py
Repoint 7 send tests get_dimensionsresolve_dimensions tests/test_api_coverage.py
Make 2 note-array Cloud-URL tests hermetic (pass in docker exec … pytest, not only CI) tests/test_board_client.py
Run the integration-tests/ mock-cloud contract suite in CI (was never collected) .github/workflows/ci.yml
i18n: localize size-indicator preset labels (English leaked into visible text and aria-label in 13 locales) web/src/components/board-size-indicator.tsx
Type contract: add schedule_enabled + port to BoardInstance (server emits them; TS type was incomplete) web/src/lib/api.ts

Results: Python 4102 passed / 11 skipped (was 4099 + 2 failing), integration 25 passed, web vitest 1166 passed, prettier/eslint clean.

Verification performed against the live next build

  • Cloud transport (14/14, real BoardClient → real mock): URL + X-Vestaboard-Token, body exactly {"characters": grid} with transitions stripped, 3×60 lands, read round-trips, 15s throttle blocks then allows, per-board token isolation holds.
  • Auto-detect (13/13): every preset + custom 8×8 + flagship + note classify correctly; 404/422 paths; token masked.
  • Web E2E (78 passed): note-arrays regression, note-pages, multi-board, multi-board-schedule (incl. board_id filtering + board-switch isolation), setup wizard, device-mismatch.
  • Pages schema migration v3→v4: correct, idempotent, no data loss.
  • TS↔Python contract (full sweep): in sync except the two BoardInstance fields fixed here.
  • i18n parity: all 14 locales carry every note-array key (no missing/orphan); the one real leak is fixed here.

✅ Investigated and cleared (were flagged, verified NOT bugs)

  • Settings migration "drops schedule.enabled": not a bug. is_schedule_enabled() falls back to the global mirror when there are no boards, and the migration stamps per-board schedule_enabled when boards exist (and never deletes the global value). No upgrade data loss.
  • "Re-POSTing boards drops schedule_enabled/port": not data loss. The Settings UI spreads the received board object ({...b, ...updates}), so runtime fields survive even though the TS type omitted them (now added for honesty).

⚠️ Still your call (out of scope / not arrays-correctness)

  1. Multi-board driving is primary-only (confirm intended). The polling loop drives only the first board (src/main.py:733) and the dashboard preview ignores the sidebar selection (active-page-display.tsx); also PUT /settings/active-page is primary-board-scoped (no board_id). Looks like later per-board-epic scope, not claimed-broken here.
  2. Unpinned ruff (ruff>=0.9.0) → nondeterministic CI format-check. ruff 0.15 reformats 64 src/ files of pre-existing code. Can fail ruff format --check for any new PR to next. Recommend pinning ruff + a one-time reformat (separate PR).
  3. detect-size "unclassifiable" 422 branch is dead — the read layer pre-filters odd grids to None, so users get a misleading "blank or unreachable" message for an unsupported size.
  4. Lower-priority test-quality gaps (finders, not all verified): scaled-board-display fit-mode scaling untested; seam gap not asserted; page-grid-selector renders note-array thumbnails at 1×1; plugin note-array cache tests assert only fetch_count.

🤖 Generated with Claude Code

Note-array boards crashed with HTTP 500 on every send path that resolved
board geometry via get_dimensions(), which only knows flagship/note and
raises ValueError on "note_array". Reproduced at runtime: POST
/templates/render/live to a note-array board 500s with
"Unknown device type: note_array. ... use resolve_dimensions()".

Fix all send sites to use resolve_dimensions(device_type, notes_wide,
notes_tall):
- /templates/render/live   (live editor output)
- /pages/{id}/send         (send a page)
- /settings/active-page    (set active page + send)
- /displays/{type}/send    (send a plugin display)
- /send-message            (also de-hardcodes the flagship 22x6 grid)

Test hardening (the existing send tests mocked get_dimensions away, so
they could never have caught this):
- Add a real regression test that sends a live template to a note-array
  board WITHOUT mocking the resolver, asserting no 500 and a true 3x60 grid.
- Repoint the 7 send-endpoint tests that patched src.api_server.get_dimensions
  to patch resolve_dimensions (get_dimensions is no longer used there).
- Make the two note-array Cloud URL tests hermetic: assert against the
  client's configured URL so they pass in the documented `docker exec ...
  pytest` dev flow (VESTABOARD_CLOUD_API_URL -> mock), not only in CI.

Also:
- devices.py: guard BoardInstance.from_dict against a JSON-null
  note_array_token ((... or "").strip() crashed on None.strip()).
- ci.yml: run the integration-tests/ mock-cloud contract suite, which was
  never collected (pyproject testpaths excludes integration-tests/).

Verified in the dev container on origin/next: full Python suite 4102
passed / 11 skipped (was 4099 + 2 failing), integration 25 passed, web
vitest 1166 passed, and a live send to a note-array board now returns 200
and lands a 3x60 grid on the bundled mock Cloud board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ardInstance TS type

- board-size-indicator.tsx rendered note-array preset names from the raw
  English NOTE_ARRAY_PRESETS[].label, leaking English into both the visible
  indicator and its screen-reader aria-label for all 13 non-English locales.
  Resolve the label via the displaySettings namespace (`presets.${id}`) --
  the same localized keys the Settings board-type selector already uses, so
  the English output is identical and existing unit tests / E2E are unchanged.

- api.ts: add `schedule_enabled` and `port` to interface BoardInstance so the
  TS contract matches what GET /settings/board actually emits (src/devices.py).
  Not a data-loss bug -- the Settings UI spreads the received board object on
  edit, preserving the runtime fields -- but the contract was incomplete.

Verified: i18n key parity clean across all 14 locales; full web vitest 1166
passed; prettier + eslint clean on changed files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeffredodd

Copy link
Copy Markdown
Member Author

Multi-board scope clarification (resolves flagged item #1): the per-board driving limitations I noted are tracked as separate open epic issues, not gaps in #1185's claims:

#1185 ships the multi-board foundation (per-board settings + schema migration, per-board schedule storage/filtering by board_id, sidebar selector + CurrentBoardProvider, unified Schedule page). Full per-board driving is intentionally deferred to #1243/#1244/#1247. The single product decision for this release: ship the foundation now with driving to follow, or hold #1185 until those land.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No a11y concerns — this PR is a net improvement.

The only web UI change is in web/src/components/board-size-indicator.tsx. It replaces the hardcoded English preset labels that were previously leaking from NOTE_ARRAY_PRESETS[].label into the ariaLabelWithLayout aria-label string with properly localized strings via tSettings(presets.${presetId}).

Verified:

  • Preset IDs align with i18n keys. The five IDs in NOTE_ARRAY_PRESETS (2_wide, 4_wide, 2_tall, 4_tall, 2x2_grid) match exactly the keys under displaySettings.presets in the PR head's en.json. No missing or misspelled keys.
  • boardSizeIndicator keys are intact. ariaLabel, ariaLabelWithLayout, and custom all exist in en.json. The ariaLabelWithLayout string ("{rows} rows by {cols} columns, {layout}") produces a coherent screen-reader announcement once layout is filled with the localized preset name.
  • No hardcoded English strings introduced. Both the visible preset label and the aria-label text are now derived from next-intl.
  • All 14 locale files already carry displaySettings.presets — no locale drift introduced.
  • Semantics unchanged. The role="img" + aria-label pattern on the indicator wrapper was correct before and remains correct; the decorative · separator sits inside the img role and is hidden from AT via the aria-label override.

This is a straightforward i18n fix that also improves the screen-reader experience for non-English users.

@jeffredodd
jeffredodd enabled auto-merge (squash) June 28, 2026 02:49
@jeffredodd
jeffredodd merged commit 881d057 into next Jun 28, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant