From 5bbd2437234270d681f98390a99a678099507b0c Mon Sep 17 00:00:00 2001 From: Ng Guoyou Date: Fri, 10 Jul 2026 07:47:03 +0800 Subject: [PATCH] test(coverage): restore to ~99.6/99.0/99.9/99.9 + gate coverage in CI Coverage had slid to 97.69/93.45/96.39/98.89 unnoticed (Vitest 2->4 remap changes in #54 plus untested-code drift) because CI ran test:unit, never the thresholds. This restores it above the original v0.0.48 baseline and makes regression impossible to miss: - ~400 new tests across 5 parallel clusters (Inspector/editor panels, meters, plugins+llm+formula, lib/core, canvas+widgets misc): 2769 -> 3151 tests. Inspector.tsx alone went 81.1% -> 99.7% stmts. All test-only; no source changes, no coverage-ignore pragmas (standing policy). Residue is documented unreachable defensive arms / library-internal branches, each justified in the PR notes. - CI client job now runs test:coverage instead of test:unit, so the vite.config ratchet gates every PR. - Thresholds re-ratcheted: statements 99->99.25, lines 99->99.5, functions 96->99.5, branches 95->98.5 (~0.4pp slack for cross-platform v8 remap jitter). - Also hardened one pre-existing flaky test (MonitorSourcesEditor rescan raced a disabled button under parallel load). Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 5 +- .../components/NowPlaying/priority.test.ts | 40 + .../components/NowPlaying/sourceList.test.ts | 4 + client/src/lib/core/agenda.test.ts | 10 + client/src/lib/core/background.test.ts | 11 + client/src/lib/core/condition.test.ts | 43 + client/src/lib/core/controls.defaults.test.ts | 135 +- client/src/lib/core/cssLint.test.ts | 6 + client/src/lib/core/cssThreats.test.ts | 21 + client/src/lib/core/format.test.ts | 49 + client/src/lib/core/gpu.test.ts | 5 + client/src/lib/core/haControls.test.ts | 30 + client/src/lib/core/haRegistry.test.ts | 76 + client/src/lib/core/layout.test.ts | 5 + client/src/lib/core/layoutEdit.test.ts | 105 +- client/src/lib/core/monitorInputs.test.ts | 15 + client/src/lib/core/palette.test.ts | 13 + client/src/lib/core/ping.test.ts | 7 + client/src/lib/core/pluginPackage.test.ts | 23 + client/src/lib/core/procWatch.test.ts | 7 + client/src/lib/core/recyclebin.test.ts | 4 + client/src/lib/core/sack.test.ts | 48 + client/src/lib/core/sensorList.test.ts | 4 + client/src/lib/core/solve.test.ts | 37 + client/src/lib/core/style.test.ts | 24 + client/src/lib/core/templatingDocs.test.ts | 27 + client/src/lib/core/textTemplate.test.ts | 6 + client/src/lib/core/widgetDocs.test.ts | 31 + client/src/lib/core/windowMatch.test.ts | 4 + client/src/lib/core/yaml.test.ts | 33 + .../src/lib/formula/engine.hostthrow.test.ts | 22 + .../formula/packageSandbox.hostthrow.test.ts | 35 + client/src/lib/monitorLabel.test.ts | 6 + client/src/lib/stt.test.ts | 36 + .../lib/widgets/AudioSwitcherHost.test.tsx | 12 + .../src/lib/widgets/BackgroundLayer.test.tsx | 11 + .../src/lib/widgets/BackgroundPanel.test.tsx | 12 + client/src/lib/widgets/ColorField.test.tsx | 27 + client/src/lib/widgets/CssEditorImpl.test.tsx | 11 + .../lib/widgets/DesignerListPanel.test.tsx | 14 + .../src/lib/widgets/DiagnosticsPanel.test.tsx | 26 +- client/src/lib/widgets/DragSnapLayer.test.tsx | 84 ++ client/src/lib/widgets/ErrorBoundary.test.tsx | 69 + client/src/lib/widgets/GroupFrame.test.tsx | 13 + .../src/lib/widgets/Inspector.wiring.test.tsx | 1268 +++++++++++++++++ .../lib/widgets/MonitorSwitchHost.test.tsx | 29 + .../src/lib/widgets/MultiInspector.test.tsx | 18 + .../src/lib/widgets/NowPlayingHost.test.tsx | 42 +- client/src/lib/widgets/Outline.test.tsx | 96 ++ client/src/lib/widgets/Select.test.tsx | 122 +- client/src/lib/widgets/SensorList.test.tsx | 14 + .../lib/widgets/WidgetErrorBoundary.test.tsx | 43 + client/src/lib/widgets/WidgetHost.test.tsx | 68 + .../lib/widgets/canvas/alignControls.test.ts | 6 + .../lib/widgets/canvas/containerAt.test.ts | 7 + .../src/lib/widgets/canvas/debugInfo.test.ts | 12 + .../lib/widgets/canvas/editorOps.bulk.test.ts | 50 + .../widgets/canvas/editorOps.place.test.ts | 11 + .../lib/widgets/canvas/editorOps.rest.test.ts | 18 + .../widgets/canvas/editorOps.sizing.test.ts | 43 + .../canvas/editorOps.structure.test.ts | 69 + .../lib/widgets/canvas/menuPreview.test.ts | 39 + .../lib/widgets/canvas/multiSelect.test.ts | 8 + .../widgets/canvas/previewTemplate.test.ts | 35 + .../lib/widgets/canvas/useBackground.test.ts | 27 + .../widgets/canvas/useCanvasPointer.test.ts | 63 + .../lib/widgets/canvas/useEditorModel.test.ts | 78 + .../lib/widgets/canvas/useKeyboard.test.ts | 63 + .../lib/widgets/canvas/usePersistence.test.ts | 4 +- .../src/lib/widgets/canvas/useSacks.test.ts | 65 + .../src/lib/widgets/canvas/useThemes.test.ts | 6 +- .../src/lib/widgets/canvas/useZoomFit.test.ts | 9 + .../lib/widgets/canvas/widgetProfile.test.ts | 16 + client/src/lib/widgets/cssEditorLint.test.ts | 8 + client/src/lib/widgets/meters/Agenda.test.tsx | 33 + .../lib/widgets/meters/AirQuality.test.tsx | 26 + .../lib/widgets/meters/AnalogClock.test.tsx | 27 +- .../lib/widgets/meters/AudioSwitcher.test.tsx | 8 + .../src/lib/widgets/meters/Battery.test.tsx | 6 + .../src/lib/widgets/meters/Calendar.test.tsx | 28 +- client/src/lib/widgets/meters/Disks.test.tsx | 20 + .../src/lib/widgets/meters/GpuPanel.test.tsx | 13 + .../meters/MonitorSourcesEditor.test.tsx | 52 +- .../widgets/meters/NetConnections.test.tsx | 52 + .../lib/widgets/meters/NowPlaying.test.tsx | 33 + .../lib/widgets/meters/ProcessWatch.test.tsx | 24 + .../lib/widgets/meters/Recyclebin.test.tsx | 19 + .../lib/widgets/meters/StickyNote.test.tsx | 21 + .../src/lib/widgets/meters/SunMoon.test.tsx | 27 +- .../lib/widgets/meters/TopProcess.test.tsx | 6 + client/src/lib/widgets/meters/Volume.test.tsx | 6 + .../src/lib/widgets/meters/Weather.test.tsx | 59 + client/src/lib/widgets/meters/Wifi.test.tsx | 6 + .../src/lib/widgets/meters/gaugeMath.test.ts | 9 + .../lib/widgets/meters/sparklineMath.test.ts | 24 + .../lib/widgets/meters/tickerFormat.test.ts | 7 + client/src/lib/widgets/plugin.test.tsx | 8 + .../widgets/plugins/AgendaSettings.test.tsx | 64 + .../lib/widgets/plugins/HaSettings.test.tsx | 69 + .../lib/widgets/plugins/LlmSettings.test.tsx | 103 ++ .../lib/widgets/plugins/MqttSettings.test.tsx | 62 +- .../plugins/NowPlayingSettings.test.tsx | 38 +- .../lib/widgets/plugins/RssSettings.test.tsx | 27 + .../widgets/plugins/StocksSettings.test.tsx | 86 +- .../widgets/plugins/TokenListField.test.tsx | 48 + .../widgets/plugins/WeatherSettings.test.tsx | 70 + .../lib/widgets/plugins/ha-backfill.test.ts | 5 + .../src/lib/widgets/plugins/llm-tts.test.ts | 13 + .../widgets/plugins/packages.parse.test.ts | 70 + .../src/lib/widgets/plugins/packages.test.ts | 104 ++ .../src/lib/widgets/plugins/weather.test.ts | 16 + client/src/lib/widgets/themeTokens.test.ts | 42 + client/src/lib/widgets/useAssistant.test.ts | 11 + client/vite.config.ts | 16 +- 114 files changed, 4876 insertions(+), 25 deletions(-) create mode 100644 client/src/lib/formula/engine.hostthrow.test.ts create mode 100644 client/src/lib/formula/packageSandbox.hostthrow.test.ts create mode 100644 client/src/lib/widgets/ErrorBoundary.test.tsx create mode 100644 client/src/lib/widgets/Inspector.wiring.test.tsx create mode 100644 client/src/lib/widgets/plugins/packages.parse.test.ts create mode 100644 client/src/lib/widgets/themeTokens.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9566f0f..8fdfaad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,10 @@ jobs: - run: npm ci - run: npm run check - run: npm run build - - run: npm run test:unit + # Coverage-gated (replaces plain test:unit): vite.config's ratchet thresholds fail this + # job on regression, so coverage can never silently drift again (it slid 99.4→97.7 + # unnoticed because CI only ran the tests, never the thresholds). + - run: npm run test:coverage - run: npm run lint # Fail if docs/widgets.md drifted from the widget registry (run `npm run gen:docs` to refresh). - run: npm run check:docs diff --git a/client/src/lib/components/NowPlaying/priority.test.ts b/client/src/lib/components/NowPlaying/priority.test.ts index f0c5026..806785a 100644 --- a/client/src/lib/components/NowPlaying/priority.test.ts +++ b/client/src/lib/components/NowPlaying/priority.test.ts @@ -115,6 +115,37 @@ describe('priority', () => { expect(sorted.at(2)!.source).toBe('barbaz'); }); + it('treats a session with no `source` field as unranked (falls through the ?? fallback)', () => { + // A malformed/legacy record missing `source` entirely (not just empty) — the `a?.source` / + // `b?.source` optional chains yield undefined, exercising the `?? '_____FIXME_____'` fallback. + // Mirrors the 'sorts media by priority in list' case, with the unranked session source-less + // instead of a non-matching string — both must land in the same (last / MAX_VALUE) slot. + const { source: _drop, ...noSource } = { ...sessionRecord, session_id: 4 }; + void _drop; + const sessions: Record = { + 0: { ...sessionRecord, session_id: 0, source: 'foobar' }, + 2: { ...sessionRecord, session_id: 2, source: 'barbaz' }, + 4: noSource as SessionRecord + }; + const priority = 'barbaz\nfoobar'; + const sorted = sortSessionsByPriority(sessions, priority); + expect(sorted.at(2)!.source).toBe('barbaz'); + expect(sorted.at(1)!.source).toBe('foobar'); + expect(sorted.at(0)!.source).toBeUndefined(); + + // Same outcome with the source-less record FIRST, so it also lands in the comparator's + // b-slot (both the `a?.source` and `b?.source` fallbacks run). + const reversed: Record = { + 0: { ...(noSource as SessionRecord), session_id: 0 }, + 2: { ...sessionRecord, session_id: 2, source: 'barbaz' }, + 4: { ...sessionRecord, session_id: 4, source: 'foobar' } + }; + const sorted2 = sortSessionsByPriority(reversed, priority); + expect(sorted2.at(2)!.source).toBe('barbaz'); + expect(sorted2.at(1)!.source).toBe('foobar'); + expect(sorted2.at(0)!.source).toBeUndefined(); + }); + it('sorts media by last updated timestamp otherwise', () => { const sessions: Record = { 0: { @@ -178,6 +209,15 @@ describe('filterIgnored', () => { const kept = filterIgnored(sessions, 'foobar2000\n\nchrome'); expect(Object.values(kept).map((s) => s.source)).toEqual(['spotify.exe']); }); + + it('treats a record with no `source` field as an empty string, not a match', () => { + const { source: _drop, ...noSource } = { ...sessionRecord, session_id: 3 }; + void _drop; + const sessions: Record = { 3: noSource as SessionRecord }; + const kept = filterIgnored(sessions, 'foobar2000'); + // '' doesn't contain 'foobar2000' → the record survives the filter. + expect(Object.keys(kept)).toEqual(['3']); + }); }); describe('upsertSession', () => { diff --git a/client/src/lib/components/NowPlaying/sourceList.test.ts b/client/src/lib/components/NowPlaying/sourceList.test.ts index e7b9345..5b01522 100644 --- a/client/src/lib/components/NowPlaying/sourceList.test.ts +++ b/client/src/lib/components/NowPlaying/sourceList.test.ts @@ -36,6 +36,10 @@ describe('moveEntry', () => { expect(moveEntry('a\nb\nc', 0, 99)).toBe('b\nc\na'); expect(moveEntry('a\nb\nc', 1, 1)).toBe('a\nb\nc'); }); + it('is a no-op when `from` is out of range', () => { + expect(moveEntry('a\nb\nc', 5, 0)).toBe('a\nb\nc'); + expect(moveEntry('a\nb\nc', -1, 0)).toBe('a\nb\nc'); + }); }); describe('normalizeList', () => { diff --git a/client/src/lib/core/agenda.test.ts b/client/src/lib/core/agenda.test.ts index fa2958c..a3f335c 100644 --- a/client/src/lib/core/agenda.test.ts +++ b/client/src/lib/core/agenda.test.ts @@ -21,6 +21,12 @@ describe('parseAgendaList', () => { ]); expect(parseAgendaList(null)).toEqual([]); }); + + it('defaults location to an empty string when absent or non-string', () => { + expect(parseAgendaList([{ summary: 'No location', start: '2027-01-02T09:00:00' }])).toEqual([ + { summary: 'No location', start: '2027-01-02T09:00:00', allDay: false, location: '' } + ]); + }); }); describe('upcomingEvents', () => { @@ -68,4 +74,8 @@ describe('formatEventWhen', () => { expect(formatEventWhen('2027-01-01', true, now)).toBe('Today'); expect(formatEventWhen('2027-01-02', true, now)).toBe('Tomorrow'); }); + + it("returns '' for an unparseable start", () => { + expect(formatEventWhen('not a date', false, now)).toBe(''); + }); }); diff --git a/client/src/lib/core/background.test.ts b/client/src/lib/core/background.test.ts index a494854..7783a2e 100644 --- a/client/src/lib/core/background.test.ts +++ b/client/src/lib/core/background.test.ts @@ -49,9 +49,20 @@ describe('parseBackgroundSpec', () => { expect(parseBackgroundSpec({ kind: 'color', src: '' })).toBeUndefined(); }); + it('treats a missing or non-string src as cleared too (the typeof src !== "string" arm)', () => { + expect(parseBackgroundSpec({ kind: 'web' })).toBeUndefined(); + expect(parseBackgroundSpec({ kind: 'color', src: 42 })).toBeUndefined(); + }); + it('trims the source', () => { expect(parseBackgroundSpec({ kind: 'web', src: ' https://x ' })?.src).toBe('https://x'); }); + + it('leaves an in-range opacity/dim untouched (the clamp01 pass-through branch)', () => { + const s = parseBackgroundSpec({ kind: 'color', src: '#fff', opacity: 0.5, dim: 0.25 }); + expect(s?.opacity).toBe(0.5); + expect(s?.dim).toBe(0.25); + }); }); describe('fit helpers', () => { diff --git a/client/src/lib/core/condition.test.ts b/client/src/lib/core/condition.test.ts index 926270d..f31c3e1 100644 --- a/client/src/lib/core/condition.test.ts +++ b/client/src/lib/core/condition.test.ts @@ -38,6 +38,11 @@ describe('parseCondition', () => { expect(parseCondition({ kind: 'appOpen' })).toBeUndefined(); expect(parseCondition({ kind: 'appOpen', matchExe: ' ' })).toBeUndefined(); }); + it('keeps matchClass and matchTitle on an appOpen', () => { + expect( + parseCondition({ kind: 'appOpen', matchClass: 'Chrome_WidgetWin_1', matchTitle: 'YouTube' }) + ).toEqual({ kind: 'appOpen', matchClass: 'Chrome_WidgetWin_1', matchTitle: 'YouTube' }); + }); it('parses a sensor condition; requires id + valid op', () => { expect(parseCondition({ kind: 'sensor', sensorId: 'cpu.total', op: '>', value: '80' })).toEqual( { @@ -57,6 +62,23 @@ describe('parseCondition', () => { parseCondition({ kind: 'sensor', sensorId: 's', op: '==', value: 5, negate: true }) ).toEqual({ kind: 'sensor', sensorId: 's', op: '==', value: '5', negate: true }); }); + it('coerces a missing/null value to an empty string', () => { + expect(parseCondition({ kind: 'sensor', sensorId: 's', op: '==' })).toEqual({ + kind: 'sensor', + sensorId: 's', + op: '==', + value: '' + }); + expect(parseCondition({ kind: 'sensor', sensorId: 's', op: '==', value: null })).toEqual({ + kind: 'sensor', + sensorId: 's', + op: '==', + value: '' + }); + }); + it('rejects a non-string op', () => { + expect(parseCondition({ kind: 'sensor', sensorId: 's', op: 5, value: '1' })).toBeUndefined(); + }); it('returns undefined for non-objects / unknown kinds', () => { expect(parseCondition(null)).toBeUndefined(); expect(parseCondition({ kind: 'nope' })).toBeUndefined(); @@ -82,6 +104,12 @@ describe('comparableOf', () => { expect(comparableOf({ kind: 'json', value: { nope: 1 } })).toBeNull(); expect(comparableOf(null)).toBeNull(); }); + it('json with a non-primitive .state (e.g. a nested object) is not comparable', () => { + expect(comparableOf({ kind: 'json', value: { state: { nested: true } } })).toBeNull(); + }); + it('an empty series (no samples yet) is not comparable', () => { + expect(comparableOf({ kind: 'series', value: [] })).toBeNull(); + }); }); describe('conditionMet — appOpen', () => { @@ -96,6 +124,11 @@ describe('conditionMet — appOpen', () => { expect(conditionMet(hide, ctx([win('x/Spotify.exe')]))).toBe(false); expect(conditionMet(hide, ctx([]))).toBe(true); }); + it('a fieldless appOpen (constructed directly, bypassing parseCondition) is inert — always shown', () => { + const inert: Condition = { kind: 'appOpen' }; + expect(conditionMet(inert, ctx([]))).toBe(true); + expect(conditionMet(inert, ctx([win('x/Spotify.exe')]))).toBe(true); + }); }); describe('conditionMet — sensor', () => { @@ -129,6 +162,16 @@ describe('conditionMet — sensor', () => { ); expect(conditionMet(c, ctx([], { light: { kind: 'text', value: 'off' } }))).toBe(false); }); + it('numeric equality/inequality (bothNum branch of == and !=)', () => { + const eq: Condition = { kind: 'sensor', sensorId: 'cpu.total', op: '==', value: '80' }; + expect(conditionMet(eq, ctx([], s(80)))).toBe(true); + expect(conditionMet(eq, ctx([], s(81)))).toBe(false); + }); + it('string inequality (bothNum false branch of !=)', () => { + const ne: Condition = { kind: 'sensor', sensorId: 'light', op: '!=', value: 'on' }; + expect(conditionMet(ne, ctx([], { light: { kind: 'text', value: 'off' } }))).toBe(true); + expect(conditionMet(ne, ctx([], { light: { kind: 'text', value: 'on' } }))).toBe(false); + }); it('negate flips, and != is the inverse of ==', () => { const ne: Condition = { kind: 'sensor', sensorId: 'cpu.total', op: '!=', value: '0' }; expect(conditionMet(ne, ctx([], s(5)))).toBe(true); diff --git a/client/src/lib/core/controls.defaults.test.ts b/client/src/lib/core/controls.defaults.test.ts index 50332f9..6a40a44 100644 --- a/client/src/lib/core/controls.defaults.test.ts +++ b/client/src/lib/core/controls.defaults.test.ts @@ -1,7 +1,20 @@ import { describe, expect, it } from 'vitest'; // Importing the defaults registers the built-in inventory as a side-effect. import './controls.defaults'; -import { detectConflicts, getControl, listControls } from './controls'; +import { detectConflicts, formatTrigger, getControl, listControls } from './controls'; +import type { ControlContext, Trigger } from './controls'; + +const baseCtx: ControlContext = { + scope: 'studio', + studio: false, + editMode: false, + menuOpen: false, + dirty: false, + hasSelection: false, + spaceDown: false, + panning: false, + previewing: false +}; describe('built-in controls', () => { it('registers the full inventory with no two controls sharing a trigger', () => { @@ -32,3 +45,123 @@ describe('built-in controls', () => { expect(detectConflicts(listControls())).toEqual([]); }); }); + +describe('canEdit (the studio.undo `when` gate)', () => { + // studio.undo's `when` is the bare `canEdit` predicate, so it isolates the branches of + // `c.studio || (c.editMode && !c.previewing)` without any other gate mixed in. + const when = getControl('studio.undo')!.when!; + + it('is true in the studio regardless of editMode/previewing (the studio short-circuit)', () => { + expect(when({ ...baseCtx, studio: true, editMode: false, previewing: true })).toBe(true); + }); + + it('is false in the overlay when edit mode is off', () => { + expect(when({ ...baseCtx, studio: false, editMode: false, previewing: false })).toBe(false); + }); + + it('is true in the overlay when edit mode is on and not previewing', () => { + expect(when({ ...baseCtx, studio: false, editMode: true, previewing: false })).toBe(true); + }); + + it('is false in the overlay edit mode while a template preview is showing', () => { + expect(when({ ...baseCtx, studio: false, editMode: true, previewing: true })).toBe(false); + }); +}); + +describe('selection-count-aware hint labels (studio.delete, studio.nudge)', () => { + it('studio.delete reads "remove" for no/singular selection and "remove (N)" for plural', () => { + const hintLabel = getControl('studio.delete')!.hintLabel!; + expect(hintLabel({ ...baseCtx })).toBe('remove'); // selectionCount undefined → the `?? 0` arm + expect(hintLabel({ ...baseCtx, selectionCount: 1 })).toBe('remove'); + expect(hintLabel({ ...baseCtx, selectionCount: 3 })).toBe('remove (3)'); + }); + + it('studio.nudge reads "nudge" for no/singular selection and "nudge (N)" for plural', () => { + const hintLabel = getControl('studio.nudge')!.hintLabel!; + expect(hintLabel({ ...baseCtx })).toBe('nudge'); // selectionCount undefined → the `?? 0` arm + expect(hintLabel({ ...baseCtx, selectionCount: 1 })).toBe('nudge'); + expect(hintLabel({ ...baseCtx, selectionCount: 2 })).toBe('nudge (2)'); + }); +}); + +describe('per-control gating predicates (when / hintWhen / hint)', () => { + const when = (id: string) => getControl(id)!.when!; + const studioCtx = { ...baseCtx, studio: true }; + + it('studio.closeMenu fires for an open menu OR a studio selection', () => { + const w = when('studio.closeMenu'); + expect(w({ ...baseCtx, menuOpen: true })).toBe(true); + expect(w({ ...studioCtx, hasSelection: true })).toBe(true); + expect(w({ ...baseCtx, hasSelection: true })).toBe(false); // selection alone, outside the studio + expect(w(baseCtx)).toBe(false); + }); + + it('studio.save requires the studio AND unsaved changes', () => { + const w = when('studio.save'); + expect(w({ ...studioCtx, dirty: true })).toBe(true); + expect(w(studioCtx)).toBe(false); + expect(w({ ...baseCtx, dirty: true })).toBe(false); + }); + + it('studio.undo is advertised only with history to undo', () => { + const hintWhen = getControl('studio.undo')!.hintWhen!; + expect(hintWhen({ ...studioCtx, canUndo: true })).toBe(true); + expect(hintWhen(studioCtx)).toBe(false); // canUndo absent → !! coerces to false + }); + + it('studio.panHold needs studio edit mode', () => { + const w = when('studio.panHold'); + expect(w({ ...studioCtx, editMode: true })).toBe(true); + expect(w(studioCtx)).toBe(false); + }); + + it('the studio-only gates (sections, panDrag, zoom) pass in the studio, fail in the overlay', () => { + for (const id of [ + 'studio.section', + 'studio.sectionNext', + 'studio.sectionPrev', + 'studio.panDrag', + 'studio.zoom' + ]) { + expect(when(id)(studioCtx)).toBe(true); + expect(when(id)(baseCtx)).toBe(false); + } + }); + + it('delete and nudge need an editable context AND a selection; their key text is fixed', () => { + for (const id of ['studio.delete', 'studio.nudge']) { + expect(when(id)({ ...studioCtx, hasSelection: true })).toBe(true); + expect(when(id)(studioCtx)).toBe(false); // no selection + expect(when(id)({ ...baseCtx, hasSelection: true })).toBe(false); // not editable + } + expect(getControl('studio.delete')!.hint!(studioCtx, [])).toBe('Del'); + expect(getControl('studio.nudge')!.hint!(studioCtx, [])).toBe('Arrows'); + }); + + it('studio.marqueeAdd hides its hint while Space is held (Space+drag pans)', () => { + const hintWhen = getControl('studio.marqueeAdd')!.hintWhen!; + expect(hintWhen(studioCtx)).toBe(true); + expect(hintWhen({ ...studioCtx, spaceDown: true })).toBe(false); + }); +}); + +describe('studio.panDrag hint (Space+drag vs middle-drag, plus the `pick ?? ts[0]` fallback)', () => { + const hint = getControl('studio.panDrag')!.hint!; + const triggers = getControl('studio.panDrag')!.triggers; + + it('picks the middle-drag trigger when Space is not held', () => { + expect(hint({ ...baseCtx, spaceDown: false }, triggers)).toBe('Middle-drag'); + }); + + it('picks the Space+left-drag trigger when Space is held', () => { + expect(hint({ ...baseCtx, spaceDown: true }, triggers)).toBe('Space+Drag'); + }); + + it('falls back to triggers[0] when no trigger matches the search (the `pick ?? ts[0]` arm)', () => { + // A triggers array with no pointer entry at all: `find` returns undefined for either branch of + // the spaceDown ternary, so `pick` stays undefined and formatTrigger falls back to ts[0]. + const noMatch: Trigger[] = [{ type: 'key', key: 'a' }]; + expect(hint({ ...baseCtx, spaceDown: false }, noMatch)).toBe(formatTrigger(noMatch[0])); + expect(hint({ ...baseCtx, spaceDown: true }, noMatch)).toBe(formatTrigger(noMatch[0])); + }); +}); diff --git a/client/src/lib/core/cssLint.test.ts b/client/src/lib/core/cssLint.test.ts index cd1d1db..e785743 100644 --- a/client/src/lib/core/cssLint.test.ts +++ b/client/src/lib/core/cssLint.test.ts @@ -40,4 +40,10 @@ describe('balanceDiagnostics', () => { expect(d).toHaveLength(1); expect(d[0].message).toMatch(/Unexpected "\)"/); }); + + it('treats an unterminated /* comment as running to the end of the source', () => { + // No closing `*/`: indexOf returns -1, so the scan skips straight to the end — brackets + // inside the dangling comment (the `{` here) are never seen, and nothing is flagged. + expect(balanceDiagnostics('/* unterminated { comment')).toEqual([]); + }); }); diff --git a/client/src/lib/core/cssThreats.test.ts b/client/src/lib/core/cssThreats.test.ts index 9447ecd..bf246f5 100644 --- a/client/src/lib/core/cssThreats.test.ts +++ b/client/src/lib/core/cssThreats.test.ts @@ -38,6 +38,14 @@ describe('scanCssThreats', () => { expect(scanCssThreats(undefined)).toEqual([]); expect(scanCssThreats('')).toEqual([]); }); + + it('truncates a long match to ~80 chars with an ellipsis', () => { + const longUrl = `url(https://evil.example/${'a'.repeat(100)})`; + const t = scanCssThreats(`.a { background: ${longUrl} }`); + expect(t).toHaveLength(1); + expect(t[0].detail.length).toBe(78); + expect(t[0].detail.endsWith('…')).toBe(true); + }); }); describe('threatSummary', () => { @@ -51,4 +59,17 @@ describe('threatSummary', () => { expect(s).toContain('2 remote resources'); expect(s).toContain('1 full-screen overlay rule'); }); + + it('uses singular wording for exactly one remote resource, omitting the overlay clause', () => { + const s = threatSummary([{ kind: 'remote-url', detail: 'url(https://h/a)' }]); + expect(s).toBe('1 remote resource (could phone home)'); + }); + + it('uses plural wording for multiple overlay rules, omitting the remote clause', () => { + const s = threatSummary([ + { kind: 'overlay', detail: 'position: fixed' }, + { kind: 'overlay', detail: 'position: sticky' } + ]); + expect(s).toBe('2 full-screen overlay rules'); + }); }); diff --git a/client/src/lib/core/format.test.ts b/client/src/lib/core/format.test.ts index d3a67d9..9b730ff 100644 --- a/client/src/lib/core/format.test.ts +++ b/client/src/lib/core/format.test.ts @@ -8,6 +8,7 @@ import { formatRate, formatScalar, guessSensorFormat, + localeDayNames, SCALAR_FORMATS } from './format'; @@ -35,6 +36,9 @@ describe('formatBytesPair', () => { it('falls back to two units when the total is missing/zero', () => { expect(formatBytesPair(1024, 0)).toContain('/'); }); + it('drops decimals entirely in the plain-byte range (unit index 0)', () => { + expect(formatBytesPair(100, 512)).toBe('100 / 512 B'); + }); }); describe('formatRate / formatPercent', () => { @@ -160,6 +164,43 @@ describe('guessSensorFormat', () => { expect(guessSensorFormat('battery.rate')).toBe('integer'); expect(guessSensorFormat('battery.capacity.remaining')).toBe('integer'); }); + + it('falls back to integer for an id matching no known shape', () => { + expect(guessSensorFormat('weird.sensor')).toBe('integer'); + }); +}); + +describe('localeDayNames', () => { + it('defaults to short English weekday names, Sunday-first', () => { + expect(localeDayNames()).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + }); + + it('returns long English weekday names when asked', () => { + expect(localeDayNames('en', 'long')).toEqual([ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday' + ]); + }); + + it('returns short/long Japanese weekday names', () => { + expect(localeDayNames('ja', 'short')).toEqual(['日', '月', '火', '水', '木', '金', '土']); + expect(localeDayNames('ja', 'long')[1]).toBe('月曜日'); + }); + + it('falls back to English for an unknown locale', () => { + expect(localeDayNames('xx')).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + }); + + it('returns a fresh copy each call (not a shared reference)', () => { + const a = localeDayNames(); + a[0] = 'mutated'; + expect(localeDayNames()[0]).toBe('Sun'); + }); }); describe('formatClock', () => { @@ -199,4 +240,12 @@ describe('formatClock', () => { it('falls back to English for an unknown locale', () => { expect(formatClock(d, 'ddd', 'xx')).toBe('Mon'); }); + + it('renders 12 for the noon/midnight hour and PM/pm after midday', () => { + const noon = new Date(2026, 5, 1, 12, 0, 0); + expect(formatClock(noon, 'h A')).toBe('12 PM'); + expect(formatClock(noon, 'hh a')).toBe('12 pm'); + const midnight = new Date(2026, 5, 1, 0, 30, 0); + expect(formatClock(midnight, 'h:mm A')).toBe('12:30 AM'); + }); }); diff --git a/client/src/lib/core/gpu.test.ts b/client/src/lib/core/gpu.test.ts index 59cace9..8308118 100644 --- a/client/src/lib/core/gpu.test.ts +++ b/client/src/lib/core/gpu.test.ts @@ -22,4 +22,9 @@ describe('gpuStats', () => { expect(gpuStats({ vramUsed: 1e9 }).some((s) => s.key === 'vram')).toBe(false); expect(gpuStats({})).toEqual([]); }); + + it('includes fan when reported', () => { + const stats = gpuStats({ fan: 42.4 }); + expect(stats).toEqual([{ key: 'fan', label: 'fan', value: '42%' }]); + }); }); diff --git a/client/src/lib/core/haControls.test.ts b/client/src/lib/core/haControls.test.ts index c9ce7e7..c4b422f 100644 --- a/client/src/lib/core/haControls.test.ts +++ b/client/src/lib/core/haControls.test.ts @@ -49,6 +49,17 @@ describe('haControls — light', () => { expect(lightColorTempKelvin(1000, attrs).data.color_temp_kelvin).toBe(2200); }); + it('defaults the kelvin range to 2000..6500 when the device reports none', () => { + expect(lightColorTempKelvin(10000, {}).data.color_temp_kelvin).toBe(6500); + expect(lightColorTempKelvin(500, {}).data.color_temp_kelvin).toBe(2000); + }); + + it('treats a light with no supported_color_modes as capability-less', () => { + expect(lightSupports({}, 'brightness')).toBe(false); + expect(lightSupports({}, 'color_temp')).toBe(false); + expect(lightSupports({}, 'rgb')).toBe(false); + }); + it('clamps rgb components to 0..255', () => { expect(lightRgb(300, -10, 128).data.rgb_color).toEqual([255, 0, 128]); }); @@ -58,6 +69,8 @@ describe('haControls — climate', () => { it('detects single-setpoint vs range', () => { expect(climateUsesRange({ temperature: 21 })).toBe(false); expect(climateUsesRange({ target_temp_high: 24, target_temp_low: 18 })).toBe(true); + // Only one side of the range set still counts as a range setpoint. + expect(climateUsesRange({ target_temp_low: 18 })).toBe(true); }); it('builds set_temperature clamped to min/max', () => { @@ -67,6 +80,12 @@ describe('haControls — climate', () => { expect(climateSetTemperature(0, attrs).data.temperature).toBe(10); }); + it('defaults min/max to 7..35 when the entity reports no range', () => { + expect(climateSetTemperature(21, {}).data.temperature).toBe(21); + expect(climateSetTemperature(99, {}).data.temperature).toBe(35); + expect(climateSetTemperature(0, {}).data.temperature).toBe(7); + }); + it('nudges the setpoint by ± one step from the current target', () => { const attrs = { temperature: 21, target_temp_step: 0.5, min_temp: 7, max_temp: 35 }; expect(climateNudge(attrs, 1).data.temperature).toBe(21.5); @@ -75,6 +94,11 @@ describe('haControls — climate', () => { expect(climateNudge({ temperature: 35, max_temp: 35 }, 1).data.temperature).toBe(35); }); + it('nudges from current_temperature when temperature is unset, or 20 when neither is set', () => { + expect(climateNudge({ current_temperature: 18 }, 1).data.temperature).toBe(18.5); + expect(climateNudge({}, 1).data.temperature).toBe(20.5); + }); + it('builds set_hvac_mode', () => { expect(climateSetHvacMode('heat')).toEqual({ service: 'set_hvac_mode', @@ -121,6 +145,12 @@ describe('haControls — fan / cover / input helpers', () => { expect(inputNumberSetValue(0, attrs).data.value).toBe(5); }); + it('defaults input_number min/max to 0..100 when the helper reports none', () => { + expect(inputNumberSetValue(50, {}).data.value).toBe(50); + expect(inputNumberSetValue(150, {}).data.value).toBe(100); + expect(inputNumberSetValue(-10, {}).data.value).toBe(0); + }); + it('builds input_select.select_option + input_text.set_value', () => { expect(inputSelectOption('Home')).toEqual({ service: 'select_option', diff --git a/client/src/lib/core/haRegistry.test.ts b/client/src/lib/core/haRegistry.test.ts index e4c93a1..a799219 100644 --- a/client/src/lib/core/haRegistry.test.ts +++ b/client/src/lib/core/haRegistry.test.ts @@ -8,6 +8,16 @@ const reg = (over: Partial = {}): HaRegistry => ({ ...over }); +// A device-less entity assigned straight to an area (for the area-ordering tests). +const ent = (id: string, area: string) => ({ + entity_id: id, + device_id: null, + area_id: area, + name: null, + original_name: id, + platform: 'p' +}); + describe('buildRegistryTree', () => { it('groups an entity under its device within the device area (inherited area)', () => { const tree = buildRegistryTree( @@ -137,6 +147,72 @@ describe('buildRegistryTree', () => { expect(byId['sensor.c'].name).toBe('sensor.c'); }); + it('falls back to the raw area_id for sorting/display when it has no matching area registry entry', () => { + const tree = buildRegistryTree( + reg({ + areas: [{ area_id: 'k', name: 'Kitchen' }], + devices: [], + entities: [ + { + entity_id: 'sensor.ghost', + device_id: null, + area_id: 'ghost', // dangling: no matching entry in `areas` + name: null, + original_name: 'Ghost', + platform: 'p' + } + ] + }), + {} + ); + const ghost = tree.find((a) => a.areaId === 'ghost'); + expect(ghost?.name).toBe('ghost'); // falls back to the raw id, not a friendly name + }); + + it('falls back to the raw device id as its display name when the device has no name', () => { + const tree = buildRegistryTree( + reg({ + devices: [{ id: 'd9', name: null, area_id: null, manufacturer: null, model: null }], + entities: [ + { + entity_id: 'switch.s', + device_id: 'd9', + area_id: null, + name: null, + original_name: 'S', + platform: 'p' + } + ] + }), + {} + ); + expect(tree[0].devices[0].name).toBe('d9'); + }); + + it('sorts several named areas alphabetically by friendly name, dangling ids by raw id', () => { + const tree = buildRegistryTree( + reg({ + areas: [ + { area_id: 'k', name: 'Kitchen' }, + { area_id: 'b', name: 'Bedroom' } + ], + devices: [], + entities: [ent('sensor.k', 'k'), ent('sensor.b', 'b'), ent('sensor.g', 'attic')] + }), + {} + ); + // 'attic' has no registry entry so it sorts by its raw id, ahead of the friendly names. + expect(tree.map((a) => a.name)).toEqual(['attic', 'Bedroom', 'Kitchen']); + }); + + it('orders two dangling area ids against each other by raw id', () => { + const tree = buildRegistryTree( + reg({ entities: [ent('sensor.z', 'zeta'), ent('sensor.a', 'alpha')] }), + {} + ); + expect(tree.map((a) => a.name)).toEqual(['alpha', 'zeta']); + }); + it('groups a device with no area under Unassigned', () => { const tree = buildRegistryTree( reg({ diff --git a/client/src/lib/core/layout.test.ts b/client/src/lib/core/layout.test.ts index 5d4d29e..daeb976 100644 --- a/client/src/lib/core/layout.test.ts +++ b/client/src/lib/core/layout.test.ts @@ -35,6 +35,11 @@ describe('parseLayout', () => { expect(parseLayout({ version: 1, monitors: { default: {} } })).toBeNull(); }); + it('returns null when a monitor entry is not an object', () => { + expect(parseLayout({ version: 1, monitors: { default: 'nope' } })).toBeNull(); + expect(parseLayout({ version: 1, monitors: { default: null } })).toBeNull(); + }); + it('drops malformed widgets but keeps valid ones', () => { const parsed = parseLayout({ version: 1, diff --git a/client/src/lib/core/layoutEdit.test.ts b/client/src/lib/core/layoutEdit.test.ts index 5362427..56f0eca 100644 --- a/client/src/lib/core/layoutEdit.test.ts +++ b/client/src/lib/core/layoutEdit.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; import type { WidgetInstance } from './layout'; -import { container, group, isContainer, leaf, type Container, type Library } from './layoutTree'; +import { + container, + group, + isContainer, + leaf, + type Container, + type Group, + type Library +} from './layoutTree'; import { allContainers, collapseContainer, @@ -119,6 +127,22 @@ describe('collapseContainer', () => { const out = findNode(collapseContainer(root, 'cell'), 'cell') as Container; expect(out.children.map((c) => c.id)).toEqual(['W1', 'W2']); }); + + it('keeps a pulled-up sub-container that still holds a widget deeper down, drops empty ones', () => { + // cell = col[ sub=col[inner=row[W1]], sub2=col[innerEmpty=row[]] ] → col[inner] + const cell = container('cell', 'col', [ + container('sub', 'col', [container('inner', 'row', [leaf(prim('W1'))])]), + container('sub2', 'col', [container('innerEmpty', 'row', [])]) + ]); + const root = container('root', 'col', [cell]); + const out = findNode(collapseContainer(root, 'cell'), 'cell') as Container; + expect(out.children.map((c) => c.id)).toEqual(['inner']); + }); + + it('is a no-op when the id names a leaf, not a container', () => { + const r = collapseContainer(tree(), 'A'); + expect((findNode(r, 'rowA') as Container).children.map((c) => c.id)).toEqual(['A', 'B']); + }); }); describe('insertChild', () => { @@ -149,6 +173,11 @@ describe('insertChild', () => { const r = insertChild(t, 'rowA', leaf(prim('D')), 99); expect((findNode(r, 'rowA') as Container).children.map((c) => c.id)).toEqual(['A', 'B', 'D']); }); + + it('is a no-op when the target parent is a leaf, not a container', () => { + const r = insertChild(tree(), 'A', leaf(prim('D'))); + expect(flowLeaves(r).map((l) => l.id)).toEqual(['A', 'B', 'C']); + }); }); describe('removeNode', () => { @@ -190,6 +219,13 @@ describe('moveNode', () => { expect(r.children.map((c) => c.id)).toEqual(['rowA', 'C']); }); + it('refuses to move a container into one of its own descendants', () => { + // rowA → its own child A would orphan the subtree; the deep cycle guard refuses. + const r = moveNode(tree(), 'rowA', 'A', 0); + expect(r.children.map((c) => c.id)).toEqual(['rowA', 'C']); + expect((findNode(r, 'rowA') as Container).children.map((c) => c.id)).toEqual(['A', 'B']); + }); + it('is a no-op for an absent node', () => { const r = moveNode(tree(), 'nope', 'rowA', 0); expect(r.children.map((c) => c.id)).toEqual(['rowA', 'C']); @@ -208,6 +244,12 @@ describe('updateNode / updateContainer', () => { const r = updateNode(tree(), 'A', (n) => (isContainer(n) ? n : { ...n, basis: { fr: 1 } })); expect(findNode(r, 'A')).toMatchObject({ basis: { fr: 1 } }); }); + + it('updateContainer is a no-op when the id names a leaf, not a container', () => { + const t = tree(); + const r = updateContainer(t, 'A', { gap: 12 }); + expect(findNode(r, 'A')).toEqual(findNode(t, 'A')); + }); }); describe('flowLeaves / allContainers', () => { @@ -267,6 +309,14 @@ describe('ungroupNode', () => { const t = tree(); expect(ungroupNode(t, 'A').children.map((c) => c.id)).toEqual(['rowA', 'C']); }); + + it('removes a group with no resolvable child (no def match, no inline child)', () => { + // A group loaded from persisted JSON can arrive with no inline child; with nothing to + // unwrap to, ungrouping drops the group leaf entirely. + const childless = { id: 'g', kind: 'group', size: { w: 1, h: 1 } } as Group; + const root = container('root', 'col', [leaf(childless), leaf(prim('Z'))]); + expect(ungroupNode(root, 'g').children.map((c) => c.id)).toEqual(['Z']); + }); }); describe('dropTarget', () => { @@ -355,4 +405,57 @@ describe('dropTarget', () => { expect(edge?.into).toBeUndefined(); expect(edge?.merge).toBeUndefined(); }); + + it('skips grid-cell interiors when intoCells is false (plain before/after)', () => { + const c0 = container('c0', 'col', [leaf(prim('A'))], { align: 'stretch' }); + const grid = container('g', 'grid', [c0, leaf(prim('L1'))], { cols: 2 }); + const root = container('root', 'col', [grid], { align: 'stretch' }); + const solved = new Map([ + ['root', { x: 0, y: 0, w: 200, h: 100 }], + ['g', { x: 0, y: 0, w: 200, h: 100 }], + ['A', { x: 0, y: 0, w: 100, h: 100 }], + ['L1', { x: 100, y: 0, w: 100, h: 100 }] + ]); + // Interior of L1's cell, but cell drops are off → before/after the leaf instead. + expect(dropTarget(root, solved, { x: 150, y: 50 }, 'W', false)).toEqual({ + parentId: 'g', + index: 2 + }); + }); + + it('never merges a dragged node with its own grid cell', () => { + const grid = container('g', 'grid', [leaf(prim('L0'))], { cols: 1 }); + const root = container('root', 'col', [grid], { align: 'stretch' }); + const solved = new Map([ + ['root', { x: 0, y: 0, w: 100, h: 100 }], + ['g', { x: 0, y: 0, w: 100, h: 100 }] + ]); + // Interior of L0's own cell while dragging L0 → falls through to "into the grid". + expect(dropTarget(root, solved, { x: 50, y: 50 }, 'L0')).toEqual({ parentId: 'g', index: 0 }); + }); + + it('prefers the first-found grid cell when two same-depth grids overlap the point', () => { + const g1 = container('g1', 'grid', [leaf(prim('L1'))], { cols: 1 }); + const g2 = container('g2', 'grid', [leaf(prim('L2'))], { cols: 1 }); + const root = container('root', 'row', [g1, g2]); + // Overlapping boxes (stale mid-drag measurements) — the later same-depth hit must not win. + const solved = new Map([ + ['root', { x: 0, y: 0, w: 100, h: 100 }], + ['g1', { x: 0, y: 0, w: 100, h: 100 }], + ['g2', { x: 0, y: 0, w: 100, h: 100 }] + ]); + expect(dropTarget(root, solved, { x: 50, y: 50 }, 'W')).toEqual({ + parentId: 'g1', + index: 0, + merge: 'L1' + }); + }); + + it('ignores a grid not yet measured when looking for cell drops', () => { + const grid = container('g', 'grid', [leaf(prim('L0'))], { cols: 1 }); + const root = container('root', 'col', [grid]); + // No 'g' box → no cell/merge drop; the leaf pass takes over (before L0's left half). + const solved = new Map([['L0', { x: 0, y: 0, w: 100, h: 100 }]]); + expect(dropTarget(root, solved, { x: 10, y: 50 }, 'W')).toEqual({ parentId: 'g', index: 0 }); + }); }); diff --git a/client/src/lib/core/monitorInputs.test.ts b/client/src/lib/core/monitorInputs.test.ts index 090aa0c..9f7904b 100644 --- a/client/src/lib/core/monitorInputs.test.ts +++ b/client/src/lib/core/monitorInputs.test.ts @@ -136,6 +136,21 @@ describe('sourceEditorRows', () => { { value: 0x1b, defaultName: 'Input 0x1B', label: 'Console', include: true, detected: false } ]); }); + + it('de-duplicates a repeated value in `detected`, keeping only the first row', () => { + const rows = sourceEditorRows([0x11, 0x11, 0x12], ''); + expect(rows.map((r) => r.value)).toEqual([0x11, 0x12]); + }); + + it('leaves a manual (undetected) row unlabeled when its spec label matches the default name', () => { + // No explicit `=label` on the 0x1b entry → parseSourceSpec defaults its label to inputName(0x1b), + // so p.label === defaultName and the manual row's `label` stays '' (not the redundant default). + const rows = sourceEditorRows([0x11], '0x11, 0x1b'); + expect(rows).toEqual([ + { value: 0x11, defaultName: 'HDMI 1', label: '', include: true, detected: true }, + { value: 0x1b, defaultName: 'Input 0x1B', label: '', include: true, detected: false } + ]); + }); }); describe('buildSourceSpec', () => { diff --git a/client/src/lib/core/palette.test.ts b/client/src/lib/core/palette.test.ts index 21edf7e..0e0749e 100644 --- a/client/src/lib/core/palette.test.ts +++ b/client/src/lib/core/palette.test.ts @@ -10,6 +10,7 @@ import { deriveTokens, rgbCss, rgbaCss, + type Bucket, type RGB } from './palette'; @@ -120,6 +121,18 @@ describe('quantize + pickSeed', () => { it('averageLuminance is 0 for an empty sample set', () => { expect(averageLuminance([])).toBe(0); }); + + it('does not let a later, less-saturated bucket overwrite the current most-saturated one', () => { + // Both buckets fail the strict accent gate (s < 0.2), so the result depends purely on the + // `mostSaturated` tracker: the first (more saturated) bucket must win, proving the second + // bucket's `s > mostSaturated.s` check is false and the assignment is skipped. + const buckets: Bucket[] = [ + { color: [150, 130, 110], count: 50 }, // s ≈ 0.16 — becomes mostSaturated first + { color: [130, 125, 120], count: 10 } // s ≈ 0.04 — must NOT replace it + ]; + expect(rgbToHsl(buckets[0].color)[1]).toBeGreaterThan(rgbToHsl(buckets[1].color)[1]); + expect(pickSeed(buckets)).toEqual([150, 130, 110]); + }); }); describe('deriveTokens', () => { diff --git a/client/src/lib/core/ping.test.ts b/client/src/lib/core/ping.test.ts index a100312..19c99c0 100644 --- a/client/src/lib/core/ping.test.ts +++ b/client/src/lib/core/ping.test.ts @@ -17,6 +17,13 @@ describe('pingSensors', () => { expect(pingSensors('')).toEqual({ ms: 'net.ping.1.1.1.1.ms', up: 'net.ping.1.1.1.1.up' }); expect(pingSensors(' ')).toEqual({ ms: 'net.ping.1.1.1.1.ms', up: 'net.ping.1.1.1.1.up' }); }); + + it('defaults a nullish host to 1.1.1.1', () => { + expect(pingSensors(null as unknown as string)).toEqual({ + ms: 'net.ping.1.1.1.1.ms', + up: 'net.ping.1.1.1.1.up' + }); + }); }); describe('pingLevel', () => { diff --git a/client/src/lib/core/pluginPackage.test.ts b/client/src/lib/core/pluginPackage.test.ts index 3a92dc9..94d8db3 100644 --- a/client/src/lib/core/pluginPackage.test.ts +++ b/client/src/lib/core/pluginPackage.test.ts @@ -176,6 +176,22 @@ describe('parsePluginPackage', () => { if (!r.ok) expect(r.reason).toContain('"templates" must be an array'); }); + it('accepts a manifest with no "templates" key at all (defaults to an empty list)', () => { + const r = parsePluginPackage( + 'weather-pack', + JSON.stringify({ + manifestVersion: 1, + id: 'weather-pack', + name: 'Weather pack', + version: '1.0.0' + }) + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.pkg.manifest.templates).toEqual([]); + expect(r.pkg.warnings).toEqual([]); + }); + it('drops a duplicate template id (second occurrence) with a warning', () => { const r = parsePluginPackage( 'weather-pack', @@ -303,6 +319,13 @@ describe('parsePluginPackage — template + param validation', () => { if (!r.ok) return; expect(r.pkg.manifest.templates[0].params).toBeUndefined(); }); + + it('accepts a minimal param spec with only a key (no label/target/targets/choices/default)', () => { + const r = tpl({ params: [{ key: 'onlykey' }] }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.pkg.manifest.templates[0].params).toEqual([{ key: 'onlykey' }]); + }); }); describe('parsePluginPackage — theme validation', () => { diff --git a/client/src/lib/core/procWatch.test.ts b/client/src/lib/core/procWatch.test.ts index 76f9098..82c2a08 100644 --- a/client/src/lib/core/procWatch.test.ts +++ b/client/src/lib/core/procWatch.test.ts @@ -15,4 +15,11 @@ describe('procWatchSensors', () => { expect(procWatchSensors('').running).toBe('proc.watch.chrome.exe.running'); expect(procWatchSensors(' ').cpu).toBe('proc.watch.chrome.exe.cpu'); }); + + it('defaults a nullish name to chrome.exe', () => { + expect(procWatchSensors(null as unknown as string).running).toBe( + 'proc.watch.chrome.exe.running' + ); + expect(procWatchSensors(undefined as unknown as string).cpu).toBe('proc.watch.chrome.exe.cpu'); + }); }); diff --git a/client/src/lib/core/recyclebin.test.ts b/client/src/lib/core/recyclebin.test.ts index cb583a8..99e59a7 100644 --- a/client/src/lib/core/recyclebin.test.ts +++ b/client/src/lib/core/recyclebin.test.ts @@ -21,4 +21,8 @@ describe('binLevel', () => { it('never flags full when the threshold is disabled (0)', () => { expect(binLevel(5, 99 * GB, 0)).toBe('has'); }); + + it('treats a null byte count as 0 when checking the threshold', () => { + expect(binLevel(5, null, GB)).toBe('has'); + }); }); diff --git a/client/src/lib/core/sack.test.ts b/client/src/lib/core/sack.test.ts index 154deeb..658c67b 100644 --- a/client/src/lib/core/sack.test.ts +++ b/client/src/lib/core/sack.test.ts @@ -12,6 +12,12 @@ const groupLeaf = (id: string, def: string): Leaf => ({ unit: { id, kind: 'group', def, size: { w: 1, h: 1 }, child: widgetLeaf(`${id}-c`) } }); +// A group leaf with no `def` (an inline group whose child is used directly). +const inlineGroupLeaf = (id: string): Leaf => ({ + id, + unit: { id, kind: 'group', size: { w: 1, h: 1 }, child: widgetLeaf(`${id}-c`) } +}); + const mkDef = (id: string, child: Leaf = widgetLeaf(`${id}-c`)): WidgetDef => ({ id, name: id.toUpperCase(), @@ -36,6 +42,15 @@ describe('sack pack/unpack', () => { }); }); + it('omits an empty tokens object but includes a non-empty one', () => { + expect(packSack({ tokens: {} })).toEqual({ kind: 'widgetsack/sack', version: 1 }); + expect(packSack({ tokens: { '--np-accent': 'red' } })).toEqual({ + kind: 'widgetsack/sack', + version: 1, + tokens: { '--np-accent': 'red' } + }); + }); + it('isSack rejects a raw widgets.json (no kind tag), and unpack rejects malformed input', () => { expect(isSack({ version: 2, monitors: {} })).toBe(false); expect(unpackSack('{"version":2,"monitors":{}}')).toBeNull(); @@ -93,4 +108,37 @@ describe('mergeLibrary', () => { expect(incoming.id).toBe('a'); expect(((incoming.child as Leaf).unit as { def: string }).def).toBe('a'); }); + + it('leaves an inline group (no def) untouched — remapRefs just recurses into its child', () => { + const into: Library = { version: 1, defs: [mkDef('a')] }; + const incomingA = mkDef('a', inlineGroupLeaf('g')); + const { library, idMap } = mergeLibrary(into, [incomingA]); + const mergedA = library.defs.find((d) => d.id === idMap.a); + const grp = (mergedA!.child as Leaf).unit as { def?: string }; + expect(grp.def).toBeUndefined(); + }); + + it('leaves a group.def unchanged when it references a def outside the incoming batch', () => { + // 'x' is already in the library and isn't part of this merge, so it never lands in idMap — + // the reference must be left exactly as-is (not blanked or rewritten). + const into: Library = { version: 1, defs: [mkDef('a'), mkDef('x')] }; + const incomingB = mkDef('b', groupLeaf('g', 'x')); + const { library, idMap } = mergeLibrary(into, [incomingB]); + const mergedB = library.defs.find((d) => d.id === idMap.b); + const grp = (mergedB!.child as Leaf).unit as { def: string }; + expect(grp.def).toBe('x'); + }); + + it('merges into an undefined library (fresh library, version defaults to 1)', () => { + const { library, idMap } = mergeLibrary(undefined, [mkDef('a')]); + expect(library).toEqual({ version: 1, defs: [mkDef('a')] }); + expect(idMap).toEqual({ a: 'a' }); + }); + + it('resolves a chain of collisions (base and base-2 both taken) by skipping past both', () => { + const into: Library = { version: 1, defs: [mkDef('a'), mkDef('a-2')] }; + const { library, idMap } = mergeLibrary(into, [mkDef('a')]); + expect(idMap.a).toBe('a-3'); + expect(library.defs.map((d) => d.id)).toEqual(['a', 'a-2', 'a-3']); + }); }); diff --git a/client/src/lib/core/sensorList.test.ts b/client/src/lib/core/sensorList.test.ts index 2044fd2..cf636dd 100644 --- a/client/src/lib/core/sensorList.test.ts +++ b/client/src/lib/core/sensorList.test.ts @@ -26,6 +26,10 @@ describe('formatSensorValue', () => { expect(formatSensorValue({ kind: 'series', value: [] })).toBe('[ ]'); }); + it('renders a non-integer last point to 1dp', () => { + expect(formatSensorValue({ kind: 'series', value: [1, 2, 3.14159] })).toBe('3.1 ⋯'); + }); + it('renders json compactly (truncated)', () => { expect(formatSensorValue({ kind: 'json', value: { a: 1 } })).toBe('{"a":1}'); }); diff --git a/client/src/lib/core/solve.test.ts b/client/src/lib/core/solve.test.ts index 65a7fd9..0eb943a 100644 --- a/client/src/lib/core/solve.test.ts +++ b/client/src/lib/core/solve.test.ts @@ -388,6 +388,15 @@ describe('collectRenderables', () => { expect(inl?.defId).toBeUndefined(); }); + it('skips flow and floating primitives whose rect is not yet measured (first frame)', () => { + const mon: MonitorLayout = { + root: container('root', 'col', [leaf(prim('F', 100, 20))], { align: 'stretch' }), + floating: [leaf(prim('W', 160, 40))] + }; + // An empty measured map (before the ResizeObserver has fired) surfaces nothing. + expect(collectRenderables(mon, new Map())).toEqual([]); + }); + it('a FLOATING group WITHOUT a def resolves its inline child (defId omitted)', () => { // No def → resolveGroup falls back to the inline child; `lf.unit.def ?? null` takes the null arm. const g = group('gi', { w: 40, h: 26 }, leaf(prim('inl', 40, 26, { type: 'bar' }))); @@ -435,6 +444,19 @@ describe('collectContainerRects', () => { expect(boxes).toHaveLength(2); }); + it('skips a container whose box is not yet measured, still descending to its children', () => { + const root = container( + 'root', + 'col', + [container('row1', 'row', [leaf(prim('A', 40, 20))], { align: 'stretch' })], + { align: 'stretch' } + ); + const mon: MonitorLayout = { root, floating: [] }; + // Only the nested row is measured — the unmeasured root is skipped, not fatal. + const solved: Solved = new Map([['row1', { x: 0, y: 0, w: 200, h: 100 }]]); + expect(collectContainerRects(mon, solved).map((b) => b.id)).toEqual(['row1']); + }); + it('does not descend into group internals (only flow-tree containers)', () => { // collectContainerRects takes no library — it walks the monitor's own flow tree only — so the // group's def need not be resolved here; an inline def reference on the group leaf is enough. @@ -497,6 +519,13 @@ describe('collectGridPlaceholders', () => { expect(cells).toHaveLength(1); // cell 0 filled by A, cell 1 is the placeholder expect(cells[0]).toEqual({ gridId: 'g', index: 1, rect: { x: 100, y: 0, w: 100, h: 100 } }); }); + + it('emits nothing for a grid whose own box is not yet measured', () => { + const grid = container('g', 'grid', [], { cols: 2, basis: { fr: 1 } }); + const root = container('root', 'col', [grid], { align: 'stretch' }); + const mon: MonitorLayout = { root, floating: [] }; + expect(collectGridPlaceholders(mon, new Map())).toEqual([]); + }); }); // ---- gridCellRects (takes a box, no solver) ------------------------------ @@ -632,6 +661,14 @@ describe('collectSplitters', () => { expect(collectSplitters(mon, solved)).toHaveLength(0); }); + it('emits no grid splitters when the grid box is unmeasured', () => { + const grid = container('g', 'grid', [leaf(prim('A', 10, 10)), leaf(prim('B', 10, 10))], { + cols: 2 + }); + const mon: MonitorLayout = { root: container('root', 'col', [grid]), floating: [] }; + expect(collectSplitters(mon, new Map())).toEqual([]); + }); + it('a col with three fr children yields two horizontal bars', () => { const col = container('c', 'col', [frLeaf('a'), frLeaf('b'), frLeaf('d')]); const mon: MonitorLayout = { root: col, floating: [] }; diff --git a/client/src/lib/core/style.test.ts b/client/src/lib/core/style.test.ts index 4425cee..1c0192a 100644 --- a/client/src/lib/core/style.test.ts +++ b/client/src/lib/core/style.test.ts @@ -117,6 +117,30 @@ describe('assembleStyles', () => { expect(assembleStyles({ monitor })).toBe(''); }); + it('skips a library def with no css (nothing pushed for its [data-def] block)', () => { + const lib: Library = { + version: 1, + defs: [{ id: 'blank', name: 'blank', size: { w: 1, h: 1 }, child: leaf(prim('x')) }] + }; + const monitor: MonitorLayout = { root: emptyRoot(), floating: [] }; + expect(assembleStyles({ library: lib, monitor })).toBe(''); + }); + + it('skips a node that is neither a container nor a leaf (malformed/stale layout data)', () => { + // LayoutNode is nominally Container | Leaf, but a hand-edited or stale widgets.json could carry + // a node satisfying neither shape (no `kind` in row/col/grid, no `unit`); walk() must degrade to + // a no-op for it rather than throwing. + const ghost = { id: 'ghost' } as unknown as ReturnType; + const monitor: MonitorLayout = { + root: container('root', 'col', [leaf(prim('a', 'color: red')), ghost]), + floating: [] + }; + expect(() => assembleStyles({ monitor })).not.toThrow(); + const css = assembleStyles({ monitor }); + expect(css).toContain('[data-w="a"]'); + expect(css).not.toContain('ghost'); + }); + it('prepends the DEFAULT_TOKENS :root base when includeDefaults, before the theme', () => { const monitor: MonitorLayout = { root: emptyRoot(), floating: [] }; const css = assembleStyles({ diff --git a/client/src/lib/core/templatingDocs.test.ts b/client/src/lib/core/templatingDocs.test.ts index 7b6dcfa..0a0d94a 100644 --- a/client/src/lib/core/templatingDocs.test.ts +++ b/client/src/lib/core/templatingDocs.test.ts @@ -44,4 +44,31 @@ describe('templatingReferenceMarkdown', () => { expect(md).toContain('{{'); expect(md).toContain('–'); }); + + it('falls back to [] when a meta has no configFields, and skips non-expr fields', () => { + const noFields: WidgetMeta[] = [{ type: 'gauge', label: 'Gauge' }]; + const nonExpr: WidgetMeta[] = [ + { + type: 'bar', + label: 'Bar', + configFields: [{ key: 'min', label: 'min', kind: 'number' }] + } + ]; + const out = templatingReferenceMarkdown([...noFields, ...nonExpr]); + expect(out).toContain('_No formula fields in the current registry._'); + }); + + it('omits the override note when target equals the field key, and blanks it when there is no help', () => { + const noHelpMetas: WidgetMeta[] = [ + { + type: 'text', + label: 'Text', + configFields: [ + { key: 'x', label: 'x', kind: 'expr', result: 'number', target: 'x' } // target === key + ] + } + ]; + const out = templatingReferenceMarkdown(noHelpMetas); + expect(out).toContain('| `text` | `x` | formula → number | |'); + }); }); diff --git a/client/src/lib/core/textTemplate.test.ts b/client/src/lib/core/textTemplate.test.ts index 9ac1aa2..b3c1108 100644 --- a/client/src/lib/core/textTemplate.test.ts +++ b/client/src/lib/core/textTemplate.test.ts @@ -46,6 +46,12 @@ describe('parseTemplate', () => { it('returns plain text unchanged', () => { expect(parseTemplate('just text')).toEqual([{ kind: 'text', text: 'just text' }]); }); + + it('tolerates an unterminated string literal inside an expr (runs to end of source)', () => { + // The opening quote never finds its match, so the inner scan exhausts the source and the + // "append the closing quote" step is skipped — the expr just ends with whatever was read. + expect(parseTemplate(`{ 'abc`)).toEqual([{ kind: 'expr', src: `'abc` }]); + }); }); describe('exprRefs / templateRefs', () => { diff --git a/client/src/lib/core/widgetDocs.test.ts b/client/src/lib/core/widgetDocs.test.ts index 7656fa0..b85f080 100644 --- a/client/src/lib/core/widgetDocs.test.ts +++ b/client/src/lib/core/widgetDocs.test.ts @@ -63,4 +63,35 @@ describe('widgetReferenceMarkdown', () => { ]); expect(piped).toContain('a \\| b'); }); + + it('documents an empty/catalog select, expr fields (with/without a target), and a numeric range', () => { + const kitchen: WidgetMeta = { + type: 'kitchen', + label: 'Kitchen', + binds: 'scalar', + configFields: [ + { key: 'src', label: 'src', kind: 'select', options: [], catalog: 'sensors' }, + { key: 'e1', label: 'e1', kind: 'expr', result: 'number', target: 'foo' }, + { key: 'e2', label: 'e2', kind: 'expr', result: 'text' }, + { key: 'range', label: 'range', kind: 'number', min: 0, max: 10, step: 1 }, + { key: 'preset', label: 'preset', kind: 'text', default: 'hi' } + ] + }; + const md = widgetReferenceMarkdown([kitchen]); + expect(md).toContain('(runtime list) — from `sensors`'); + expect(md).toContain('→ number (sets `foo`)'); + expect(md).toContain('| `e2` | expr | '); + expect(md).toContain('→ text |'); + expect(md).toContain('min 0, max 10, step 1'); + expect(md).toContain('| `preset` | text | "hi" |'); + }); + + it('falls back to the type when label is missing, "scalar" when binds is missing, and notes interactive widgets', () => { + const blob: WidgetMeta = { type: 'blob', interactive: true }; + const md = widgetReferenceMarkdown([blob]); + expect(md).toContain('### blob — `blob`'); + expect(md).toContain('![blob widget]'); + expect(md).toContain('binds a `scalar` sensor'); + expect(md).toContain('- **Interactive:** catches clicks in passive mode'); + }); }); diff --git a/client/src/lib/core/windowMatch.test.ts b/client/src/lib/core/windowMatch.test.ts index 2f20548..5aa0a71 100644 --- a/client/src/lib/core/windowMatch.test.ts +++ b/client/src/lib/core/windowMatch.test.ts @@ -40,6 +40,10 @@ describe('windowMatches / anyWindowMatches', () => { expect(windowMatches(spotify, { exe: 'spotify.exe', title: 'Nope' })).toBe(false); expect(windowMatches(spotify, { className: 'Chrome_WidgetWin_?' })).toBe(true); }); + + it('a mismatched class rejects the window even when nothing else is specified', () => { + expect(windowMatches(spotify, { className: 'Nope' })).toBe(false); + }); it('a fieldless rule never matches (no accidental catch-all)', () => { expect(windowMatches(spotify, {})).toBe(false); }); diff --git a/client/src/lib/core/yaml.test.ts b/client/src/lib/core/yaml.test.ts index bf4d6b9..a7bdc5c 100644 --- a/client/src/lib/core/yaml.test.ts +++ b/client/src/lib/core/yaml.test.ts @@ -9,6 +9,12 @@ describe('toYaml', () => { expect(toYaml('hello')).toBe('hello'); }); + it('emits false, and non-finite numbers as null', () => { + expect(toYaml(false)).toBe('false'); + expect(toYaml(NaN)).toBe('null'); + expect(toYaml(Infinity)).toBe('null'); + }); + it('quotes ambiguous strings (numbers, bools, empties, indicators)', () => { expect(toYaml('123')).toBe('"123"'); expect(toYaml('true')).toBe('"true"'); @@ -21,6 +27,12 @@ describe('toYaml', () => { expect(toYaml({ type: 'gauge', min: 0, max: 100 })).toBe('type: gauge\nmin: 0\nmax: 100'); }); + it('quotes an ambiguous object key (numeric-like, `: `-bearing, dash-led)', () => { + expect(toYaml({ '123': 1 })).toBe('"123": 1'); + expect(toYaml({ 'a: b': 1 })).toBe('"a: b": 1'); + expect(toYaml({ '-x': 1 })).toBe('"-x": 1'); + }); + it('nests objects on indented lines', () => { expect(toYaml({ rect: { x: 1, y: 2 } })).toBe('rect:\n x: 1\n y: 2'); }); @@ -34,6 +46,27 @@ describe('toYaml', () => { expect(toYaml({ actions: [], config: {} })).toBe('actions: []\nconfig: {}'); }); + it('renders a top-level array / empty array / empty object', () => { + expect(toYaml([1, 2, 3])).toBe('- 1\n- 2\n- 3'); + expect(toYaml([])).toBe('[]'); + expect(toYaml({})).toBe('{}'); + }); + + it('renders an array item object with a single key with no continuation lines', () => { + expect(toYaml({ actions: [{ domain: 'media' }] })).toBe('actions:\n - domain: media'); + }); + + it('keeps a blank line inside a multi-line block scalar', () => { + expect(toYaml({ css: 'a\n\nb' })).toBe('css: |-\n a\n\n b'); + }); + + it('quotes a top-level scalar string containing a tab or leading/trailing whitespace', () => { + expect(toYaml('a\tb')).toBe('"a\\tb"'); + expect(toYaml(' leading')).toBe('" leading"'); + expect(toYaml('trailing ')).toBe('"trailing "'); + expect(toYaml('a #comment-ish')).toBe('"a #comment-ish"'); + }); + it('renders a multi-line string as a block scalar (e.g. css)', () => { const y = toYaml({ css: '.a {\n color: red;\n}' }); expect(y).toBe('css: |-\n .a {\n color: red;\n }'); diff --git a/client/src/lib/formula/engine.hostthrow.test.ts b/client/src/lib/formula/engine.hostthrow.test.ts new file mode 100644 index 0000000..6fa389f --- /dev/null +++ b/client/src/lib/formula/engine.hostthrow.test.ts @@ -0,0 +1,22 @@ +// evalExpr's last-resort catch: QuickJS evaluates the expression fine, but bringing the RESULT +// across (ctx.dump) explodes host-side on a pathologically deep value — evalExpr must yield null, +// not an exception. Kept in its OWN file with NO afterAll dispose: the aborted dump strands QuickJS +// handles, and disposing the runtime afterwards would trip QuickJS's leak assertion — the vitest +// worker teardown reclaims the WASM instance instead. (engine.test.ts owns the normal +// init/dispose lifecycle.) +import { describe, expect, it } from 'vitest'; +import { evalExpr, initFormulaEngine, isFormulaEngineReady } from './engine'; + +describe('evalExpr — host-side dump failure', () => { + it('returns null when the result is too deep to serialize back, and keeps working', async () => { + await initFormulaEngine(); + expect(isFormulaEngineReady()).toBe(true); + // Built iteratively (no sandbox recursion): a 100k-deep nested array evaluates fine inside + // QuickJS but the host-side dump recurses over it and throws → the catch → null. + const deep = + '(function () { var a = []; var cur = a; for (var i = 0; i < 100000; i++) { var n = []; cur.push(n); cur = n; } return a; })()'; + expect(evalExpr(deep, {})).toBeNull(); + // The engine survives for subsequent, well-behaved evaluations. + expect(evalExpr('1 + 1', {})).toBe(2); + }); +}); diff --git a/client/src/lib/formula/packageSandbox.hostthrow.test.ts b/client/src/lib/formula/packageSandbox.hostthrow.test.ts new file mode 100644 index 0000000..e136daf --- /dev/null +++ b/client/src/lib/formula/packageSandbox.hostthrow.test.ts @@ -0,0 +1,35 @@ +// evalJson's last-resort catch: a hostile script THROWS a value so deep that dumping the error +// object host-side (ctx.dump(out.error)) explodes — the tick must come back as { ok:false }, +// never as an exception. Kept in its OWN file with NO dispose calls: the aborted dump strands +// QuickJS handles, and disposing that runtime would trip QuickJS's leak assertion — the vitest +// worker teardown reclaims the WASM instance instead. (packageSandbox.test.ts owns the normal +// create/dispose lifecycle.) +import { describe, expect, it } from 'vitest'; +import { createPackageSandbox } from './packageSandbox'; + +// requests() throws a 100k-deep nested array (built iteratively — no sandbox recursion): QuickJS +// propagates the value as the eval error, and serializing it back to the host blows the dump. +const THROW_DEEP_SCRIPT = ` +module.exports = { + requests: function () { + var a = []; var cur = a; + for (var i = 0; i < 100000; i++) { var n = []; cur.push(n); cur = n; } + throw a; + }, + transform: function () { return []; } +}; +`; + +describe('package sandbox — host-side dump failure', () => { + it('reports ok:false when the thrown error value cannot be brought across', async () => { + const r = await createPackageSandbox(THROW_DEEP_SCRIPT); + expect(r.ok).toBe(true); + if (!r.ok) return; + + const out = r.sandbox.requests(); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.error.length).toBeGreaterThan(0); + // The sandbox survives for subsequent, well-behaved calls on the same runtime. + expect(r.sandbox.transform([])).toEqual({ ok: true, value: [] }); + }); +}); diff --git a/client/src/lib/monitorLabel.test.ts b/client/src/lib/monitorLabel.test.ts index 4add2e8..efe7805 100644 --- a/client/src/lib/monitorLabel.test.ts +++ b/client/src/lib/monitorLabel.test.ts @@ -16,6 +16,12 @@ describe('compareMonitorOptions', () => { 'DISPLAY10' ]); }); + + it('treats two "default" entries as equal', () => { + expect( + compareMonitorOptions({ key: 'default', name: 'A' }, { key: 'default', name: 'B' }) + ).toBe(0); + }); }); describe('monitorOptionLabel', () => { diff --git a/client/src/lib/stt.test.ts b/client/src/lib/stt.test.ts index 3c2a1e6..347d3f1 100644 --- a/client/src/lib/stt.test.ts +++ b/client/src/lib/stt.test.ts @@ -252,4 +252,40 @@ describe('stt', () => { expect(() => rec2.cancel()).not.toThrow(); expect(tracks[0]!.stop).toHaveBeenCalled(); }); + + it('cancel() skips rec.stop() when the recorder is already inactive, still releasing the mic', async () => { + const tracks = [{ stop: vi.fn() }]; + const created: FakeRecorder[] = []; + class FakeRecorder { + state = 'recording'; + mimeType = ''; + ondataavailable: ((e: { data: Blob }) => void) | null = null; + onstop: (() => void) | null = null; + onerror: (() => void) | null = null; + stopped = 0; + constructor() { + created.push(this); + } + start(): void { + /* no-op */ + } + stop(): void { + this.stopped++; + } + static isTypeSupported(): boolean { + return false; + } + } + vi.stubGlobal('MediaRecorder', FakeRecorder); + vi.stubGlobal('navigator', { + mediaDevices: { getUserMedia: async () => ({ getTracks: () => tracks }) } + }); + + const rec = await startRecording(); + const inst = created[0]!; + inst.state = 'inactive'; // the recorder already stopped on its own + rec.cancel(); + expect(inst.stopped).toBe(0); // no redundant stop() on an inactive recorder + expect(tracks[0]!.stop).toHaveBeenCalled(); // mic still released + }); }); diff --git a/client/src/lib/widgets/AudioSwitcherHost.test.tsx b/client/src/lib/widgets/AudioSwitcherHost.test.tsx index 287cce6..436a142 100644 --- a/client/src/lib/widgets/AudioSwitcherHost.test.tsx +++ b/client/src/lib/widgets/AudioSwitcherHost.test.tsx @@ -136,4 +136,16 @@ describe('AudioSwitcherHost (container wiring)', () => { }); expect(listAudioOutputs).not.toHaveBeenCalled(); }); + + it('a stray interval tick after teardown is swallowed by the alive guard', async () => { + const setIntervalSpy = vi.spyOn(window, 'setInterval'); + const { unmount } = render(); + await act(async () => undefined); // settle the mount refresh + const tick = setIntervalSpy.mock.calls.at(-1)?.[0] as () => void; + unmount(); + listAudioOutputs.mockClear(); + // clearInterval normally prevents this; if a queued tick slips through it must not refresh. + tick(); + expect(listAudioOutputs).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/lib/widgets/BackgroundLayer.test.tsx b/client/src/lib/widgets/BackgroundLayer.test.tsx index 405dd9a..29cca70 100644 --- a/client/src/lib/widgets/BackgroundLayer.test.tsx +++ b/client/src/lib/widgets/BackgroundLayer.test.tsx @@ -57,4 +57,15 @@ describe('BackgroundLayer', () => { ); expect(container.querySelector('.bg-layer')).toBeNull(); }); + + it('uses the src verbatim when no resolveSrc is injected (identity default)', () => { + const { container } = render(); + const fill = container.querySelector('.bg-fill') as HTMLElement; + expect(fill.style.backgroundImage).toContain('x.png'); + }); + + it('renders nothing when the spec has no src at all', () => { + const { container } = render(); + expect(container.querySelector('.bg-layer')).toBeNull(); + }); }); diff --git a/client/src/lib/widgets/BackgroundPanel.test.tsx b/client/src/lib/widgets/BackgroundPanel.test.tsx index 8b4cba1..1d420b6 100644 --- a/client/src/lib/widgets/BackgroundPanel.test.tsx +++ b/client/src/lib/widgets/BackgroundPanel.test.tsx @@ -96,6 +96,12 @@ describe('BackgroundPanel — colour kind', () => { const color = container.querySelector('input[type="color"]') as HTMLInputElement; expect(color.value).toBe('#0b0b0e'); }); + + it('falls back to the default colour when src is missing entirely', () => { + const { container } = renderPanel({ kind: 'color' }); + const color = container.querySelector('input[type="color"]') as HTMLInputElement; + expect(color.value).toBe('#0b0b0e'); + }); }); describe('BackgroundPanel — web kind', () => { @@ -107,6 +113,12 @@ describe('BackgroundPanel — web kind', () => { fireEvent.blur(url); expect(h.patchBg).toHaveBeenCalledWith({ src: 'https://b' }); }); + + it('renders an empty URL field for a web background with no src yet', () => { + const { container } = renderPanel({ kind: 'web' }); + const url = container.querySelector('input[type="text"]') as HTMLInputElement; + expect(url.value).toBe(''); + }); }); describe('BackgroundPanel — media kinds (image/video)', () => { diff --git a/client/src/lib/widgets/ColorField.test.tsx b/client/src/lib/widgets/ColorField.test.tsx index 787614f..0383648 100644 --- a/client/src/lib/widgets/ColorField.test.tsx +++ b/client/src/lib/widgets/ColorField.test.tsx @@ -39,4 +39,31 @@ describe('ColorField', () => { fireEvent.click(screen.getByLabelText('clear')); expect(onChange).toHaveBeenCalledWith(''); }); + + it('does not re-commit an unchanged value on blur', () => { + const onChange = vi.fn(); + render(); + fireEvent.blur(screen.getByLabelText('accent')); + expect(onChange).not.toHaveBeenCalled(); // no redundant undo/save entry + }); + + it('falls back to a generic swatch label when no ariaLabel is given', () => { + render(); + // Without an ariaLabel prop the swatch names itself "colour swatch" (default a11y label). + expect(screen.getByLabelText('colour swatch')).toBeInTheDocument(); + }); + + it('resyncs the text field when the external value prop changes (store-previous idiom)', () => { + const onChange = vi.fn(); + const { rerender } = render(); + const text = screen.getByLabelText('accent') as HTMLInputElement; + // A local (uncommitted) edit lives only in `text` until blur. + fireEvent.change(text, { target: { value: 'gr' } }); + expect(text.value).toBe('gr'); + // An external change (Clear / theme switch / selecting another widget) overwrites the local text. + rerender(); + expect((screen.getByLabelText('accent') as HTMLInputElement).value).toBe('blue'); + // The resync must not have committed the in-progress local edit. + expect(onChange).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/lib/widgets/CssEditorImpl.test.tsx b/client/src/lib/widgets/CssEditorImpl.test.tsx index ba9eb35..44e4374 100644 --- a/client/src/lib/widgets/CssEditorImpl.test.tsx +++ b/client/src/lib/widgets/CssEditorImpl.test.tsx @@ -49,6 +49,17 @@ describe('CssEditorImpl', () => { expect(onChange.mock.calls.at(-1)?.[0]).toBe('abc'); }); + it('does not fire onChange for a doc-unchanged update (selection move only)', async () => { + const onChange = vi.fn(); + const { container } = render(); + await waitFor(() => expect(cmEditor(container)).toBeTruthy()); + // A transaction with no document change still runs the updateListener — but must not commit. + act(() => { + view(container).dispatch({ selection: { anchor: 1 } }); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + it('fires onBlur with the current doc when the content blurs', async () => { const onBlur = vi.fn(); const { container } = render(); diff --git a/client/src/lib/widgets/DesignerListPanel.test.tsx b/client/src/lib/widgets/DesignerListPanel.test.tsx index dde78ac..c73f148 100644 --- a/client/src/lib/widgets/DesignerListPanel.test.tsx +++ b/client/src/lib/widgets/DesignerListPanel.test.tsx @@ -7,6 +7,7 @@ vi.mock('../overlay', () => ({ copyToClipboard: vi.fn(() => Promise.resolve(true import DesignerListPanel from './DesignerListPanel'; import { copyToClipboard } from '../overlay'; +import { registerTemplates, unregisterTemplates, TEMPLATES } from '../core/templates'; import type { Library } from '../core/layoutTree'; import type { DefEditor } from './canvas/useDefEditor'; @@ -134,6 +135,19 @@ describe('DesignerListPanel template groups', () => { const { getByText } = render(); expect(getByText('Network').closest('.dl-item')!.className).toContain('cur'); }); + + it('labels a plugin template group "Templates · " (built-ins stay plain "Templates")', () => { + // A plugin package contributes its own group; the built-in group keeps the unqualified header. + registerTemplates('My Pack', [{ ...TEMPLATES[1], id: 'pack-system', name: 'Pack System' }]); + try { + const { getByText } = render(); + expect(getByText('Templates')).toBeTruthy(); + expect(getByText('Templates · My Pack')).toBeTruthy(); + expect(getByText('Pack System')).toBeTruthy(); + } finally { + unregisterTemplates('My Pack'); + } + }); }); describe('DesignerListPanel header actions', () => { diff --git a/client/src/lib/widgets/DiagnosticsPanel.test.tsx b/client/src/lib/widgets/DiagnosticsPanel.test.tsx index 4083437..50100e6 100644 --- a/client/src/lib/widgets/DiagnosticsPanel.test.tsx +++ b/client/src/lib/widgets/DiagnosticsPanel.test.tsx @@ -39,7 +39,8 @@ import { reloadWindow, requestDiagnostics, setSubsystemProfiling, - setWindowInteractive + setWindowInteractive, + type SubsystemTiming } from '../diag'; import { resetWidgetProfile, widgetCosts } from './canvas/widgetProfile'; @@ -170,6 +171,29 @@ describe('DiagnosticsPanel backend subsystem timings', () => { expect(container.querySelectorAll('.diag-cost-row[data-hot]').length).toBe(1); await flush(); }); + + it('ignores a timings poll that resolves after unmount (alive guard)', async () => { + // The mount poll's promise is held open past unmount; resolving it then must hit the + // `if (alive)` bail — no setState on the dead tree (which would warn on console.error). + let resolveTimings: ((t: SubsystemTiming[]) => void) | undefined; + vi.mocked(getSubsystemTimings).mockImplementationOnce( + () => + new Promise((r) => { + resolveTimings = r; + }) + ); + const { unmount } = render(); + await flush(); + unmount(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => { + resolveTimings?.([ + { key: 'sensors', avgMs: 1, lastMs: 1, samples: 1, perSec: 1, msPerSec: 1 } + ]); + }); + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); }); describe('DiagnosticsPanel widget render cost', () => { diff --git a/client/src/lib/widgets/DragSnapLayer.test.tsx b/client/src/lib/widgets/DragSnapLayer.test.tsx index 4273faa..172d4d9 100644 --- a/client/src/lib/widgets/DragSnapLayer.test.tsx +++ b/client/src/lib/widgets/DragSnapLayer.test.tsx @@ -94,6 +94,28 @@ const MIXED_FLOATING_LAYOUT = JSON.stringify({ } }); +// LAYOUT with its only floating zone under a DIFFERENT id — for the stale-probe test, where a drag +// snapshot taken against z1 resolves after the zone set has been re-snapshotted to this one. +const OTHER_ZONE_LAYOUT = JSON.stringify({ + version: 2, + monitors: { + default: { + root: { id: 'root', kind: 'col', children: [] }, + floating: [ + { + id: 'z2', + unit: { + id: 'z2', + type: 'zone', + rect: { x: 960, y: 0, w: 960, h: 1080 }, + config: { matchExe: 'notepad.exe' } + } + } + ] + } + } +}); + // The layout loadLayoutRaw() serves; swapped per test, reset in beforeEach. let layoutJson = LAYOUT; @@ -134,6 +156,7 @@ vi.mock('../overlay', () => ({ })); import DragSnapLayer from './DragSnapLayer'; +import { currentMonitor } from '@tauri-apps/api/window'; const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0)))); const pollOnce = () => act(async () => void (await new Promise((r) => setTimeout(r, 80)))); @@ -363,6 +386,67 @@ describe('DragSnapLayer (zone widgets)', () => { expect(snapWindow).toHaveBeenCalledWith(7, { x: 0, y: 0, w: 960, h: 1080 }); }); + it('tolerates currentMonitor() resolving null — keeps the default origin, still loads zones', async () => { + vi.mocked(currentMonitor).mockResolvedValueOnce(null); + pointerProbe.mockResolvedValue({ x: 480, y: 540, shift: true }); + render(); + await settle(); + + act(() => handlers['win_drag_start']?.({ payload: { hwnd: 7 } })); + await pollOnce(); + await act(async () => { + handlers['win_drag_end']?.({ payload: { hwnd: 7 } }); + await Promise.resolve(); + }); + + // The default monitor (origin 0,0 · scale 1) stands in, so the zone still arms and snaps. + expect(snapWindow).toHaveBeenCalledWith(7, { x: 0, y: 0, w: 960, h: 1080 }); + }); + + it('drops the highlight when a stale probe resolves against a re-snapshotted zone set', async () => { + // tick() captures the phys zone list BEFORE awaiting the probe but reads the local-rect map + // AFTER it — so a probe that resolves after a layout change + a new drag re-snapshot can arm a + // zone that is no longer in the map. The highlight must fall back to null, not crash. + let resolveProbe: ((p: { x: number; y: number; shift: boolean }) => void) | null = null; + pointerProbe + .mockResolvedValueOnce({ x: 480, y: 540, shift: true }) // tick 1: arms z1 → highlight shows + .mockImplementationOnce( + () => + new Promise((r) => { + resolveProbe = r; + }) + ) // tick 2: held open across the re-snapshot + .mockResolvedValue({ x: 0, y: 0, shift: false }); + const view = render(); + await settle(); + + // Drag 1 snapshots zone z1; its first tick arms z1, the second blocks on the deferred probe. + act(() => handlers['win_drag_start']?.({ payload: { hwnd: 7 } })); + await act(async () => void (await new Promise((r) => setTimeout(r, 120)))); + expect(view.container.querySelector('.zone-drag-highlight')).toBeTruthy(); + expect(resolveProbe).toBeTruthy(); + + // The layout is replaced (only zone now z2) and a new drag re-snapshots the refs. + layoutJson = OTHER_ZONE_LAYOUT; + await act(async () => { + handlers['layout_changed']?.({ payload: {} }); + await new Promise((r) => setTimeout(r, 0)); + }); + act(() => handlers['win_drag_start']?.({ payload: { hwnd: 7 } })); + + // The stale probe (armed inside z1, absent from the new local map) resolves → highlight null. + await act(async () => { + resolveProbe?.({ x: 480, y: 540, shift: true }); + await new Promise((r) => setTimeout(r, 0)); + }); + expect(view.container.querySelector('.zone-drag-highlight')).toBeNull(); + + await act(async () => { + handlers['win_drag_end']?.({ payload: { hwnd: 7 } }); + await Promise.resolve(); + }); + }); + it('unmounting before currentMonitor() resolves cancels the setup (alive guard)', async () => { pointerProbe.mockResolvedValue({ x: 480, y: 540, shift: true }); const view = render(); diff --git a/client/src/lib/widgets/ErrorBoundary.test.tsx b/client/src/lib/widgets/ErrorBoundary.test.tsx new file mode 100644 index 0000000..1781e8e --- /dev/null +++ b/client/src/lib/widgets/ErrorBoundary.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import ErrorBoundary from './ErrorBoundary'; + +// A child that throws during render (what an error boundary catches) when given a value to throw. +function Boom({ thrown }: { thrown?: unknown }) { + if (thrown !== undefined) throw thrown; + return
healthy
; +} + +describe('ErrorBoundary', () => { + beforeEach(() => { + // React logs every caught render error to console.error; silence it (and let us assert on it). + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => vi.restoreAllMocks()); + + it('renders its children when they do not throw', () => { + const { getByText, queryByRole } = render( + + + + ); + expect(getByText('healthy')).toBeTruthy(); + expect(queryByRole('alert')).toBeNull(); + }); + + it('shows a labelled inline fallback with the error message when a child throws', () => { + const { getByRole } = render( + + + + ); + expect(getByRole('alert').textContent).toContain('HA settings failed to render: kaboom'); + }); + + it('falls back to the default label when none is given', () => { + const { getByRole } = render( + + + + ); + expect(getByRole('alert').textContent).toContain('This panel failed to render: kaboom'); + }); + + it('stringifies a non-Error throw (a plugin can throw anything)', () => { + const { getByRole } = render( + + + + ); + expect(getByRole('alert').textContent).toContain('failed to render: plain failure'); + }); + + it('logs the crash to console.error, using the label when given and "panel" otherwise', () => { + render( + + + + ); + expect(console.error).toHaveBeenCalledWith('panel crashed', expect.any(Error)); + render( + + + + ); + expect(console.error).toHaveBeenCalledWith('HA settings crashed', expect.any(Error)); + }); +}); diff --git a/client/src/lib/widgets/GroupFrame.test.tsx b/client/src/lib/widgets/GroupFrame.test.tsx index b88ce90..182d58b 100644 --- a/client/src/lib/widgets/GroupFrame.test.tsx +++ b/client/src/lib/widgets/GroupFrame.test.tsx @@ -163,6 +163,19 @@ describe('GroupFrame', () => { expect(onSelect).toHaveBeenCalledWith({ id: 'grp-1' }); }); + it('subsequent moves after crossing the slop keep firing onChange (moved is latched)', () => { + const onChange = vi.fn(); + const { container } = render( + + ); + const ov = overlay(container); + fireEvent.pointerDown(ov, { button: 0, pointerId: 1, clientX: 10, clientY: 10 }); + fireEvent.pointerMove(ov, { pointerId: 1, clientX: 40, clientY: 10 }); // crosses the slop + // back within the slop radius of the press: still a move — the gate only applies pre-drag + fireEvent.pointerMove(ov, { pointerId: 1, clientX: 12, clientY: 10 }); + expect(onChange).toHaveBeenCalledTimes(2); + }); + it('right-button press is a free-move (skipFlow): commit skips flow + suppresses the next menu', () => { const onChange = vi.fn(); const onCommit = vi.fn(); diff --git a/client/src/lib/widgets/Inspector.wiring.test.tsx b/client/src/lib/widgets/Inspector.wiring.test.tsx new file mode 100644 index 0000000..983c9d9 --- /dev/null +++ b/client/src/lib/widgets/Inspector.wiring.test.tsx @@ -0,0 +1,1268 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import Inspector from './Inspector'; +import { + container, + group, + leaf, + type Group, + type WidgetDef, + type WidgetInstance +} from '../core/layoutTree'; +import type { ConfigField } from '../core/widget'; +import type { LayoutOp } from './ops'; +import { registerTemplates, unregisterTemplates } from '../core/templates'; + +// The hover popover renders real meters; every wiring test asserts on the popover's text, never a +// live render, so stub it to a cheap div. +vi.mock('./WidgetPreview', () => ({ default: () => null })); + +// CodeMirror is lazy-loaded (Suspense) and never resolves under happy-dom, so the real CssEditor +// renders only its loading placeholder and its onBlur never fires. Swap it for a plain textarea whose +// blur forwards the current text — that's the exact contract the Inspector's css setters consume. +vi.mock('./CssEditor', () => ({ + default: (props: { + value?: string; + ariaLabel?: string; + placeholder?: string; + onBlur: (value: string) => void; + }) => ( +