Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions client/src/lib/components/NowPlaying/priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, SessionRecord> = {
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<number, SessionRecord> = {
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<number, SessionRecord> = {
0: {
Expand Down Expand Up @@ -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<number, SessionRecord> = { 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', () => {
Expand Down
4 changes: 4 additions & 0 deletions client/src/lib/components/NowPlaying/sourceList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
10 changes: 10 additions & 0 deletions client/src/lib/core/agenda.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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('');
});
});
11 changes: 11 additions & 0 deletions client/src/lib/core/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
43 changes: 43 additions & 0 deletions client/src/lib/core/condition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -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();
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
135 changes: 134 additions & 1 deletion client/src/lib/core/controls.defaults.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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]));
});
});
6 changes: 6 additions & 0 deletions client/src/lib/core/cssLint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
21 changes: 21 additions & 0 deletions client/src/lib/core/cssThreats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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');
});
});
Loading