Frontend Unit Tests (card logic)¶
Pure-JS unit tests for the card's own computational logic β coordinate math, hit-testing, state accessors, validation engines, colour/theme resolution β run with Node's built-in test runner, no browser and no build.
- Run them all:
npm run test:units(node --test "src/**/*.test.mjs"). - Run one file:
node --test src/state/rooms-logic.test.mjs. - 1047 cases across 103 files, currently all green. Node β₯ 21 (native glob).
(The
node --testsummary line is the authority for these numbers β rerunnpm run test:unitsrather than trusting this doc's total.)
This is one of three separate frontend test tracks β see the three tracks.
How this set got big (2026-07-04, then the 2026-08 audit campaign)
It started as 13 files / 102 cases (mostly map/coordinate helpers). A
frontend-logic coverage audit then found that most of src/ is genuinely
plumbing (renderers, event-binding, hass-orchestration β correctly not
unit-tested), but a dense set of pure-derivation engines was untested:
the room access-graph, the reorder engine, start-block reasons, rule
validation, resolvedTheme, the learning summary/progress, map compose
geometry, floor-scope, and a few computations that had leaked inline into
renderers. Those were closed in three waves; the later floor-texture map view
then added the compositor + material-resolver engines (src/textures/),
taking the suite to 524 cases. The 2026-08 audit campaign added the next
tier: per-defect regression files across src/actions/ (service-failure
and refusal surfacing), src/bindings/ (confirm dialogs, rejected-import
surfacing), src/renderers/ (the review modal, job summary, live
charge/zone banners), the steps editors under src/state/, and the
i18n/font wiring β taking it to 904 cases across 91 files at the campaign's close.
The map¶
Grouped by src/ area. Each file's test names carry inline [XX-N] ids (the
same convention as the Python suite; the check_legend_drift.py legend gate is
Python-only, so these .mjs ids are not legend-checked).
src/state/ β panel state accessors, coordinate math, and derivation engines¶
The card's state lives in applyXState(proto) mixins. These tests build a stub
proto (Object.create over the mixin'd prototype) and assert the pure
derivations β no DOM, no hass.
| File | Cases | What it guards |
|---|---|---|
rooms-logic.test.mjs |
29 | The room access-graph validator (validateRoomAccessUpdate + DFS cycle / self-reference / duplicate-edge / missing-ref / single-inbound rules β issue codes) and the start-button readiness reason-code precedence (no_rooms_included > already_cleaning > returning_to_dock > vacuum_error); orphanedRooms. |
order-engine.test.mjs |
24 | The reorder engine β _sortOrderedItems (numeric order + id tiebreak; the Number(null)===0 sort-to-front footgun is pinned), move-to-position clamp+splice, swap-by-id, 1-based reindex, and the scope-preview wrappers. Drives room drag-and-drop β backend number entities. |
room-rules-logic.test.mjs |
28 | ruleEntityDescriptor (domain β category + allowed-operator set), roomRulesDraftIsValid (Save-enabled gate incl. the clean_passes 1|2 rule), scored entity search tiers, operator-group filtering. |
theme-resolve.test.mjs |
27 | resolvedTheme β the deterministic 4-layer merge (room-fill palette defaults + floor-texture material defaults seeded from FLOOR_TEXTURE_REGISTRY, incl. the map-rotate token; computed -eff layers excluded β active theme β working draft), plus filteredThemeTokens / filteredPresetIds (facet AND/OR + search filtering). |
map-compose-and-viewport.test.mjs |
25 | composeToSegments (custom-segment draft β save payload: subtract-ordering, group/room_id resolution, rotated-rect β polygon trig), clampMapTransform (off-screen-recovery clamp), applyMapZoom (focal-point), loadComposeDraftFromSegments (id-counter advance). |
learning-derive.test.mjs |
37 | endLearningJob (actual-vs-predicted summary with the >0 guards), _dashboardJobIsActive (terminal-status gate for the whole live-job UI), room count / timeline / banner fallbacks, estimate keying. |
room-editor-matching.test.mjs |
22 | Preset snap-back matcher (_editorFieldsMatchProfile), option-list builder (omit β hide picker), clean-mode / intensity canonicalization, carpet gate. |
run-profiles-steps.test.mjs |
20 | The run-profile STEPS editor draft state (charge-step / room-group authoring buffer): draft wiring, the "capture current room setup" snapshot, and editing an existing profile clones (never mutates) the stored steps. |
steps-order.test.mjs |
20 | The pure step-mutation helpers in steps-order.js β immutable derive-next-array primitives the steps editor rides on; mirrors the backend normalize for save. |
state/resolved-entity.test.mjs |
5 | live:ENT-10 β the card must use the entity the BACKEND resolved, not one it derives itself. batteryLevel() / isCharging() built sensor.${objectId}_battery and binary_sensor.${objectId}_charging from the vacuum's object_id, the same naming assumption ENT-1/ENT-5 fixed in Python β a fifth copy, in a language those fixes cannot reach. Issue #49: the backend resolves the battery sensor and it reads 100 while the card derives an id that does not exist. Pins the precedence, the unchanged derivation fallback, and [RE-4] pins the shipped SOURCE so a green mirror over un-rewired code cannot pass. |
saved-zones-group.test.mjs |
17 | savedZonesGrouped (group under room, live map order, trailing "Unassigned" bucket) and selectedSavedZoneIds. |
access-graph-model.test.mjs |
16 | access-graph-model.js β normalizeRefs (scalar / null / mixed / blanks) + the graph-model derivations. |
steps-manifest.test.mjs |
15 | renderStepsManifest β the pure "Runs As" stepβHTML helper shared by the run-profiles panel and the standalone profile card (per-step-type rendering, room-name lookup + fallback, single-vs-mixed clean-mode hint). |
room-access-logic.test.mjs |
15 | accessEditableRooms (exclude self/dock, hide rooms claimed by another under the single-inbound rule). |
zone-draft.test.mjs |
15 | _rectToNormalized zone coord conversion + multi-zone draft list, and canDrawZone β draw-gate lights up over the live backdrop or an active VA raster (Roborock cv-mode path), still gated by supportsZoneClean. |
external-jobs-group.test.mjs |
13 | externalWizardGroups (v1/v2 segment grouping) + wizard split-map / default-room seeding. |
run-profiles-normalize.test.mjs |
12 | _normalizeRunProfilesPayload (unwrap bare array / profiles / saved_run_profiles fallback; library guard). |
coded-label.test.mjs |
10 | The shared codeβtranslated-label resolver (namespace lookup with params, fallback rules). |
mascot-facing.test.mjs |
10 | mascotFacingSign β screen-space facing from content motion under map rotation, with a sub-threshold deadband (hold last facing). |
room-profiles-name.test.mjs |
10 | makeRoomProfileName (slug + custom_ prefix + _2/_3 collision suffix) + sorted profile lists. |
core-battery.test.mjs |
9 | batteryState β 5-band classifier with charging override + ordered thresholds, incl. the absent-vs-zero guard. |
maintenance-logic.test.mjs |
9 | findUpkeepItem (case-insensitive kind+component match) and canInvokeMaintenanceReset. |
metrics-logic.test.mjs |
9 | findMetricsSaveCandidate (match on profile_key AND room_slug across the selected source). |
steps-queue-order.test.mjs |
9 | The steps-queue order adapter β reordering the live ad-hoc queue (rooms + charge/wait breaks) as ONE list β room-order writes + recomputed after_index per break. |
affordance-and-warning.test.mjs |
8 | Regression (CENSUS-2): canPauseRun / canResumeRun must not trust a prepended data source that doesn't carry the field. |
clean-mode-options.test.mjs |
8 | Issue #48 card half: the stored DISPLAY label and the adapter TOKEN resolve to one clean-mode option. |
fault-label.test.mjs |
7 | Fault-label resolution (CARD-3): backend sends a KEY, never text; an unrecognised fault falls back to the raw vendor code (honest) rather than blank. |
live-map-url.test.mjs |
7 | _liveMapImageUrl β backdrop URL + camera cache-bust. |
live-pose-overlay.test.mjs |
7 | Live-pose merge β fresh pose overrides only the moving fields; static segmentation preserved. |
access-issue-label.test.mjs |
6 | Backend access-issue codes resolve to translated sentences with params; unknown codes degrade safely. |
live-trail.test.mjs |
6 | The live pose trail β accumulates anchors while cleaning, de-dups stationary repeats, freezes docked then continues the SAME trace on resume (recharge case), bounded length. |
mascot-dwell.test.mjs |
6 | Dwell-debounced mascot room tracker (state machine). |
room-hit-test.test.mjs |
6 | roomIdAtContentPct β pixel-exact room hit-test (Y-flip + contain letterbox). |
area-label-anchor.test.mjs |
5 | Per-room draggable mΒ² chip anchor. |
map-rotation.test.mjs |
5 | unrotatePct β pointer β content frame on a rotated map. |
review-exclude-reason.test.mjs |
5 | The review exclude-reason resolver β preset chip passes its value, "custom" passes the user's trimmed text (falls back to the literal custom). |
hidden-regions.test.mjs |
4 | Per-map user-drawn masks β accessor, draw gate, rect conversion. |
map-stale.test.mjs |
4 | CARD-2 clause 1: the map-stale selectors driving the dim treatment + "last seen" badge (no snapshot β not stale; fresh source β not stale). |
steps-order-leading-break.test.mjs |
3 | Regression (CARD-6 clause 1): insertChargeStep / insertWaitStep refuse a leading/trailing break (unsupported positions). |
map-room-labels.test.mjs |
2 | Per-vacuum map room-label visibility toggle. |
src/cards/ β standalone-card logic¶
| File | Cases | What it guards |
|---|---|---|
dashboard-dispatch.test.mjs |
23 | The dashboard card's pure run-launcher logic. |
zone-geometry.test.mjs |
14 | The card's pure zone-clean geometry (verified vs the real X10 camera). |
map-room-color.test.mjs |
9 | Map room-fill colour resolution (roomFillTokenName / palette / override, normalizeHex) β the frontend mirror of the backend _hex_color_or_none. |
card-suggestions.test.mjs |
5 | Managed-vacuum detection for the card-config suggestions (companion entities carrying a vacuum_entity_id attribute pointing back at the vacuum). |
zone-repeat.test.mjs |
5 | canZoneRepeat (CARD-6 clause 2) β the zone-repeat capability gate. |
src/theme-tokens/ β token-shape + floor-scoping logic¶
| File | Cases | What it guards |
|---|---|---|
helpers.test.mjs |
22 | makeTokenLabel (key β title-case label) + makeGroupedToken / makeTypedGroupToken (type-validated, finite-range-merge token factories). |
floor-scope.test.mjs |
14 | detectFloorScope / sliceThemeByTypes / clampThemeScalars β targeted theme export/import (longest-name-wins scoping, out-of-range scalar clamp). |
animals.test.mjs |
4 | Per-animal theme-token shape (editor lists only the tokens an animal themes). |
flatten.test.mjs |
12 | flattenThemeBuckets / hexWithAlpha / alphaApplies β the ONE rule for turning {tokens, colors, alpha} into a CSS-ready map. Guards the compose-not-concatenate invariant (a key in both colors and alpha must bake to 8-char hex, never resolve to the bare alpha number) and the composed / unappliedAlpha accounting that keeps a clean report honest. Eight of the twelve go red against the concatenation this replaced. |
src/textures/ β floor-material render math¶
Pure math + config resolution behind the map's floor-texture view (mask Γ colour Γ opacity compositing and the roomβmaterial mapping). No canvas, no DOM.
| File | Cases | What it guards |
|---|---|---|
floor-texture-compositor.test.mjs |
7 | compositeFloorTexture(w, h, baseColor, layers) β the maskΓcolourΓopacity blend the floor view samples: white mask reveals the layer colour over the opaque base, opacity scales the reveal, mid-grey blends halfway, layers stack bottomβtop, and degenerate inputs (short luma / opacity β€ 0 / missing) are skipped safely. Output stays fully opaque (a floor is a solid surface). |
floor-texture-resolver.test.mjs |
10 | resolveFloorType(room) (spec {floor_type, carpet_type} β carpet_low/carpet_high; legacy combined forms; hardwood/laminateβwood, graniteβgranite_light aliases; direct registry keys; case/whitespace-insensitive; unknownβdefault) and normalizeFloorRotationDeg(deg) (non-finiteβ0, quantise to 0.1Β° so getComputedStyle noise can't churn the cache, wrap to [-180, 180)). |
src/renderers/ β pure math extracted from render methods¶
These computations previously lived inline in renderX(ctx) methods; they were
extracted into exported pure functions (behaviour-preserving; the render methods
now delegate) so they could be unit-tested off the render path.
| File | Cases | What it guards |
|---|---|---|
theme-parsers.test.mjs |
28 | _parseColorMix / parseScalarThemeValue / alphaPercentFromHex (+ serialize/clamp) β the CSS color-mix + scalar + alpha-hex parsers behind the theme editor. |
map-geometry.test.mjs |
20 | _polygonCentroid (signed-area + degenerate fallback), _savedZoneBbox, _overlayTransform (object-fit:contain letterbox). |
maintenance-derive.test.mjs |
24 | maintenanceDueInBucket(item, now, t) (due-in projection with 3-day / 0.1-h-per-day guards; now/t injected), needs-attention verdict, remaining-percent branch. Plus [FREQ-1..6] on _formatMaintenanceFrequency (added 2026-08-07, when it was found to be dead code its one call site could never reach): sentence case not title case (Π Π°Π· Π² Π½Π΅Π΄Π΅Π»Ρ and Einmal pro Woche must survive unchanged), hyphens preserved while underscores collapse (every 3-6 months is a real bundled value the old [_-]+ class would have mangled), Turkish dotless i (Δ°ki, not Iki), and [FREQ-5] β a language value that is TRUTHY but stringifies to "" must not throw, which is the shape that made String(x \|\| "en") crash the whole view via toLocaleUpperCase(""). These drive the REAL function through applyMaintenanceRenderers, not a transcription of it. |
job-summary.test.mjs |
15 | The Job Summary modal β the surface that closes the gap between the job JSON and what the user is told about a run. |
review-error-badge.test.mjs |
14 | The review list's captured-run-errors (run_errors) badge β surfacing the backend's harvested error rows. |
setup-reconciliation.test.mjs |
12 | CARD-7/RP-019 β the reconciliation review banner (renderReconciliationPanel, driven through the whole renderSetupView() since it's a closure, not a proto method). |
progress-and-format.test.mjs |
6 | Regression (FE-LRN-1 +1): two renderers that showed a plainly wrong thing β the live-progress list's completed/current dispatch fall-through. |
maintenance-census6.test.mjs |
6 | CENSUS-6 β server-baked English reached the user in all 18 locales; the maintenance renderer must translate, not pass through. |
language-control-font.test.mjs |
5 | The typeface picker inside the language control (P3 of the OpenDyslexic accessibility feature). |
review-matcher-clean-mode.test.mjs |
5 | CC-5 β the review profile-matcher's clean-mode chip row comparison. |
review-absent-battery.test.mjs |
4 | REV-6 β an externally-captured run with absent battery must not render "Battery 0". |
review-truncation-note.test.mjs |
4 | REV-5 β the run list is cut to limit (50) while the headline stat counts all runs; the truncation must be said. |
rooms.test.mjs |
4 | confidenceTooltip (CARD-2 clause 3) β finite sample counts fold into the tooltip; a count of 0 still renders; numeric strings coerce. |
i18n-escaping-contract.test.mjs |
4 | live:I18N-1 β the tRaw escaping contract in renderers/shared.js (what escapes, what must not double-escape). |
learning-charge-status.test.mjs |
3 | The live charge banner gates ONLY on liveChargeStatus() β a charge_wait phase has an empty room timeline, so the old queue-gated placement never rendered it. |
learning-zone-status.test.mjs |
3 | The live zone banner β same shape as the charge banner: gated on liveZoneStatus(), never on the room queue. |
run-profiles-unsupported-position.test.mjs |
3 | CARD-6 clause 1 display half β an EXISTING saved profile with a leading/trailing break renders the unsupported-position notice. |
maintenance-clock-modal-host-css.test.mjs |
3 | MHC-* β every evcc-* class the counter picker emits is DEFINED in MODAL_HOST_STYLES, the body-portal cascade that actually renders it (the shadow cascade interpolates that export, so the flow is one-way). Caught .evcc-clock-candidate-main, emitted with no rule in either cascade, on its first run. |
maintenance-clock-candidate-value.test.mjs |
3 | MCV-* β a candidate's reading is rounded to one decimal. The same picker read "414 min" on a Dreame and "206.618888888889 h" on a Roborock; non-numeric states pass through untouched. |
room-estimate-allocated.test.mjs |
2 | CARD-2 clause 2 β the room-estimate modal's ALLOCATED-source labeling (not just default). |
metrics-edge-mopping-capability.test.mjs |
2 | CARD-8 β the "Edge Mopping" subtitle chip must be capability-gated, not shown whenever edge_mopping is truthy. |
src/actions/ β service-call wrappers (failure/refusal surfacing)¶
The audit campaign's CARD-1 family: a failed or refused service call must
reach the user, not just the console. callService passes
notifyOnError: false (suppressing HA's own error toast), so these wrappers
are responsible for telling the user β and the two shapes (a throwing call
vs a normal response carrying {success: false, reason}, the operational-
refusal contract) are pinned separately.
Every fake at the hass seam must return the ENVELOPE. hass.callService(...,
returnResponse) resolves to {context, response}, never the bare payload. Until
2026-09-13 all four fixtures below returned it bare β a fixture agreeing with the CALLER,
not the callee β so callService's refusal check, which read result.success off the
ENVELOPE and could therefore never fire, sat under six green cases for months. Wrap fakes
with envelope() from _test-host.mjs. Ablating the unwrap in core.js now turns 8
of these cases red while every no-toast control stays green.
| File | Cases | What it guards |
|---|---|---|
rooms-clear-applied.test.mjs |
8 | Defect #8 β post-apply room mutations (updateRoomFields, persistRoomOrdering) drop the pending applied stepped run profile so Start runs the just-edited FLAT selection, not the saved step sequence. |
core-envelope-shape.test.mjs |
7 | CES-* β the {context, response} ENVELOPE contract: callService returns the UNWRAPPED payload; a null response falls back to the envelope (not null, which would report a save that took as a failure); REPLICA RNGP3ZBE parity with cards/_shared.js::callResponse; the second funnel (_callThemeService) revives; and the predicate stays NARROW β {ok:false}/{updated:false}/{status:"error"} remain their own funnels' business, or one refusal toasts twice. |
core-refusal-shape.test.mjs |
6 | CRS-* β the generic callService wrapper toasts a non-throwing {success: false, reason} response. Fixture returns the real envelope. |
core-service-failure.test.mjs |
6 | A throwing service call surfaces as a toast (was: console.error + resolve null). |
rooms-cancel-through-seam.test.mjs |
6 | The card-cancel bypass β Cancel must call the integration's cancel_active_job, never stock vacuum.return_to_base (which left the tracker believing the job was live). |
fetch-failure-tristate.test.mjs |
5 | A FAILED fetch is not rendered as a confident empty result β "never loaded" / "failed" / "genuinely empty" stay distinct. |
theme-refusal-toast.test.mjs |
5 | CARD-9 β _callThemeService, the single funnel of all ~11 theme actions, toasts the theme services' own refusal shape. |
map-zone-clean-refusal-toast.test.mjs |
4 | MZ-2 β the zone-clean call site routes through the refusal-toasting wrapper (e.g. job_in_progress). |
rooms-start-refusal-toast.test.mjs |
4 | FE-ERR-1 β startCleaning itself routes through the refusal-surfacing wrapper. |
review-snapshot-origin.test.mjs |
3 | getLearningHistorySnapshot forwards EVERY filter param (the origin chip was a silent no-op β destructured away). |
rooms-retry-missed-map-scope.test.mjs |
3 | CARD-5 β retryMissedRooms refuses (map_mismatch) when the incomplete-run log's map disagrees with the active map. |
src/i18n/ and src/styles/¶
| File | Cases | What it guards |
|---|---|---|
i18n/rtl.test.mjs |
10 | isRTL (ar/he/fa/ur/iw/ps) + applyDir + the bidi-isolate (FSI/PDI) behavior of translate β the logic half of the RTL gates. |
i18n/font-store.test.mjs |
8 | FS-* β the per-user typeface store: the coverage gate reads FONT_SUPPORT (never a hardcoded "en"), region tags resolve to their base, unverified locales are refused even when Latin-script, scalar clamps. |
i18n/card4-untranslated-strings.test.mjs |
1 | CARD-4 β the three base-catalog strings that were missing from every shipped locale pack stay present everywhere. |
styles/typeface-wiring.test.mjs |
6 | live:FONT-1 β the --evcc-font-family token is SET by a rule the shadow tree reads and READ where it resolves (the token was set by a rule nothing read, for two days). |
src/bindings/ and src/controllers/¶
| File | Cases | What it guards |
|---|---|---|
controllers/learning-controller-progress.test.mjs |
19 | getRoomProgressSnapshot (per-room progress flags/percent) and _computeProgressPercent. |
bindings/room-rules-save-reaches-service.test.mjs |
6 | RRS-* β reachability: the rule save/delete bindings actually reach saveRoomRules, which did not exist (call-only since eae291fa), so both sites evaluated to undefined and the drawer closed with no error while the rule vanished. Also pins that an EMPTY list is sent (deleting the last rule is a clear, not a no-op) and that a refusal keeps the drawer open. room-rules-payload.test.mjs could not catch this β it proves the payload is built, never that it reaches a sink. |
bindings/maintenance-clock-response-unwrap.test.mjs |
6 | MCU-* β the maintenance-COUNTER picker's two service calls unwrap .response. The picker rendered "no suitable counter found" over a list of six because it read result.candidates off the envelope; the save path additionally treats {status:"error"} as a refusal rather than a save that took. |
bindings/room-rules-payload.test.mjs |
9 | _buildRulePayload β rule draft β persisted payload (modifier/blocker action, clean_passes 1|2 gate, fan-out filter). |
bindings/setup-reconciliation.test.mjs |
9 | CARD-7/RP-019 β the reconcile-room Update/Dismiss bindings (bare-proto mixin + recorded _onAll handlers). |
bindings/theme-import-rejected.test.mjs |
8 | CARD-9(3) β rejected-keys surfacing at BOTH theme-import entry points. |
bindings/theme-overwrite-confirm.test.mjs |
7 | CARD-9(2) β the Save button's overwrite-confirm dialog (no silent overwrite). |
bindings/map-segments-staleness.test.mjs |
5 | The map-segments slice follows the ACTIVE MAP β not fetched once for the life of the element. |
bindings/room-editor-save-rejection.test.mjs |
4 | _roomEditorSaveWasRejected β a rejected room-editor save is detected and surfaced. |
bindings/rooms-queue-missed-map-scope.test.mjs |
4 | CARD-5 binding side β the missed-rooms retry binding passes the recorded map id into the map-scoped action. |
bindings/theme-preset-confirm.test.mjs |
3 | CARD-9 β preset-chip clicks confirm before discarding a dirty working draft, and re-clicking the already-active theme is a no-op. |
How it's tested¶
Every file uses Node's built-in runner and assertions β no jsdom, no DOM shim:
Two patterns:
- Mixin accessors (
src/state, controllers) β build a stub proto and set only the fields the method reads: - Module functions (
src/cards,src/theme-tokens, the extracted renderer helpers) β import and call directly, likesrc/cards/map-room-color.test.mjs.
Anything that needs a rendered card (DOM, computed styles, visual output) is a render-harness spec instead, not a unit test.
CI¶
npm run test:units runs in
node-tests.yml
on any src/** change, in the pinned Playwright image. So a src/state/ or
src/renderers/ refactor that breaks a derivation fails CI on push. The gate
authority is npm run test:units returning non-zero on any failure β mirroring
pytest tests --no-cov for the Python suite.
The three tracks¶
The frontend has three separate test tracks, none of them pytest:
- Unit tests (this doc) β
npm run test:units, pure logic at the module boundary. - Render harness β
npm run test:harness, the rendered card headless: smoke, visual regression, CVD, shape marks, intake. - i18n / sanitiser node tests β
npm run check:i18n(scripts/check-i18n.mjs, the translate/escape trust-model incl. the double-escape case) andscripts/sanitize-locale.test.mjs(the locale-sanitiser browser gates). Both run innode-tests.yml.