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
62 changes: 62 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,68 @@ pass with the simplest code, then refactor under green.
the solver, sensor math, serialization shape) can be exercised without real media,
hardware, or a window.

### Testing pyramid

Keep the suite bottom-heavy. Choose the lowest layer that can prove the behavior; move upward only
when the risk depends on framework, bridge, browser, or operating-system integration.

1. **Unit / pure-seam tests (most tests).** Exercise `lib/core/*`, reducers, parsers, schedulers,
geometry/math helpers, serialization contracts, and Rust pure seams directly. These should be
fast, deterministic, exhaustive around meaningful boundaries, and require no React, Tauri,
filesystem, network, clock, or hardware unless that dependency is the subject of the seam.
2. **Component / integration tests (fewer tests).** Use Testing Library for observable React
behavior and focused adapter tests for store, filesystem, event, or command orchestration. Mock
at the outer boundary (for example Tauri `invoke`/`listen`), not between internal collaborators,
and verify the data or UI that crosses the boundary.
3. **E2E tests (fewest tests).** Use Playwright for critical studio/overlay journeys whose value
comes from real browser layout, focus, pointer/keyboard interaction, persistence wiring, or role
behavior. Cover representative happy paths and high-impact regressions; do not duplicate every
pure or component-level permutation in E2E.
4. **Manual / hardware smoke checks (exceptional).** Reserve these for Windows APIs, real media,
GPU/audio/display hardware, or destructive/external integrations that cannot be made
deterministic. Document the procedure and keep automated pure seams underneath it. Expensive or
intrusive Rust smoke tests must stay explicitly `#[ignore]` with a reason.

If a behavior can be tested at two layers, prefer the lower layer and keep at most one higher-level
test to prove the wiring. A healthy pyramid has many tiny domain tests, a smaller set of component
and adapter tests, and a compact E2E suite—not the same assertions repeated at every layer.

### Write high-value tests

A test is valuable when it would fail for a plausible user-visible regression and clearly explain
what contract broke. Optimize for defect detection and confidence, not test count or incidental
coverage.

- **Assert behavior and contracts, not implementation.** Prefer returned values, persisted JSON,
emitted bridge shapes, store snapshots, rendered text/roles, and user interactions. Avoid testing
private helpers through mocks, exact call sequences that are not contractual, DOM structure added
only for styling, or broad snapshots that accept unrelated churn.
- **Prove the risk that motivated the change.** A bug test should fail before the fix for the right
reason. For races and lifecycle bugs, control ordering and assert the harmful outcome cannot occur
(stale overwrite, duplicate listener, overlapping request, mutation of an old snapshot, leaked
demand). For bridge changes, assert both serialization shape and the matching consumer contract.
- **Cover meaningful partitions, not permutations.** Usually test the representative success path,
important boundary values, and distinct failure/recovery paths. Use table-driven tests when the
same rule has several inputs; do not multiply tests for equivalent cases merely to raise coverage.
- **Keep tests deterministic and isolated.** Control time, randomness, async completion, filesystem
paths, and event order. Never depend on the public network, local user state, real hardware, test
execution order, or arbitrary sleeps. Await async work and restore timers, listeners, registries,
stores, mocks, and temporary files during cleanup.
- **Use the smallest realistic fixture.** Include only the fields needed to expose the behavior,
while keeping fixtures valid according to the real Rust↔TypeScript or layout/widget contract.
Prefer builders and focused examples over giant production dumps.
- **Keep assertions precise but resilient.** Assert all outputs that define the contract and no
irrelevant formatting/order unless it is itself required. Ensure error-path tests verify both the
error and the safety property—for example that corrupt input does not overwrite a valid file.
- **Avoid low-value tests.** Do not add tests that only prove a mock returned its configured value,
repeat TypeScript's type checking, exercise framework/library behavior, or mirror the source line
by line. Delete or consolidate redundant tests when a stronger test subsumes them.
- **Treat test diagnostics as failures to investigate.** Unexpected React `act` warnings, unhandled
rejections, console errors, leaked timers, and post-test state updates indicate an incomplete or
racy test even if the runner exits successfully. Fix the lifecycle or await the work; suppress a
diagnostic only when it is a narrowly identified environment artifact and keep other errors
visible.

`state.rs::updater` is covered by a `#[cfg(test)] mod tests` block (create/update/delete);
keep it green when you touch the reducer. Pure-seam tests in `sensors.rs` / `ha.rs` show the
same pattern to follow for new seams.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/src/lib/components/NowPlaying/np-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const npSource: SensorSource = {
// Make sure the media feed is flowing into mediaStore (idempotent), then mirror the active
// session into the hub on every change. Re-derives selection the same way the widget does
// (ignore filter + priority sort) so the sensors track exactly what's shown.
startMediaSource();
await startMediaSource();
const push = () => {
const s = mediaStore.getSnapshot();
const active = sortSessionsByPriority(
Expand Down
27 changes: 19 additions & 8 deletions client/src/lib/components/NowPlaying/priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ describe('priority', () => {

const sorted = sortSessionsByPriority(sessions, priority);

expect(sorted.at(2)!.source).toBe('barbaz');
expect(sorted.at(0)!.source).toBe('barbaz');
expect(sorted.at(1)!.source).toBe('foobar');
expect(sorted.at(0)!.source).toBe('notinlist');
expect(sorted.at(2)!.source).toBe('notinlist');
});

it('sorts media by playing status after sorting by priority list', () => {
Expand Down Expand Up @@ -129,9 +129,9 @@ describe('priority', () => {
};
const priority = 'barbaz\nfoobar';
const sorted = sortSessionsByPriority(sessions, priority);
expect(sorted.at(2)!.source).toBe('barbaz');
expect(sorted.at(0)!.source).toBe('barbaz');
expect(sorted.at(1)!.source).toBe('foobar');
expect(sorted.at(0)!.source).toBeUndefined();
expect(sorted.at(2)!.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).
Expand All @@ -141,9 +141,9 @@ describe('priority', () => {
4: { ...sessionRecord, session_id: 4, source: 'foobar' }
};
const sorted2 = sortSessionsByPriority(reversed, priority);
expect(sorted2.at(2)!.source).toBe('barbaz');
expect(sorted2.at(0)!.source).toBe('barbaz');
expect(sorted2.at(1)!.source).toBe('foobar');
expect(sorted2.at(0)!.source).toBeUndefined();
expect(sorted2.at(2)!.source).toBeUndefined();
});

it('sorts media by last updated timestamp otherwise', () => {
Expand Down Expand Up @@ -171,9 +171,20 @@ describe('priority', () => {

const sorted = sortSessionsByPriority(sessions, priority);

expect(sorted.at(2)!.source).toBe('notinlist');
expect(sorted.at(0)!.source).toBe('notinlist');
expect(sorted.at(1)!.source).toBe('barbaz');
expect(sorted.at(0)!.source).toBe('foobar');
expect(sorted.at(2)!.source).toBe('foobar');
});

it('matches priority entries as exact lines rather than substrings', () => {
const sessions: Record<number, SessionRecord> = {
0: { ...sessionRecord, session_id: 0, source: 'foo' },
1: { ...sessionRecord, session_id: 1, source: 'foobar' }
};

const sorted = sortSessionsByPriority(sessions, 'foobar');

expect(sorted.map((s) => s.source)).toEqual(['foobar', 'foo']);
});
});

Expand Down
40 changes: 23 additions & 17 deletions client/src/lib/components/NowPlaying/priority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,30 @@ export const sortSessionsByPriority = (
currentSessions: Record<number, SessionRecord>,
sourcePriority: string
) => {
const orderedMedia = Object.values(currentSessions)
.sort(
(a, b) =>
(b?.timestamp_updated?.secs_since_epoch ?? 0) -
(a?.timestamp_updated?.secs_since_epoch ?? 0)
)
.sort((a, b) => {
let aPriority = sourcePriority.indexOf(a?.source?.toLowerCase() ?? '_____FIXME_____');
let bPriority = sourcePriority.indexOf(b?.source?.toLowerCase() ?? '_____FIXME_____');
const rank = new Map(
sourcePriority
.split('\n')
.map((source) => source.trim().toLowerCase())
.filter(Boolean)
.map((source, index) => [source, index])
);
const priorityOf = (session: SessionRecord): number =>
rank.get(session.source?.toLowerCase() ?? '') ?? Number.MAX_SAFE_INTEGER;
const playingOf = (session: SessionRecord): number =>
session.last_model_update?.Model?.playback?.status === 'Playing' ? 0 : 1;

aPriority = aPriority === -1 ? Number.MAX_VALUE : aPriority;
bPriority = bPriority === -1 ? Number.MAX_VALUE : bPriority;

return aPriority - bPriority;
})
.sort((_, b) => (b.last_model_update?.Model?.playback?.status === 'Playing' ? 1 : -1));

return orderedMedia;
return Object.values(currentSessions).sort((a, b) => {
const playing = playingOf(a) - playingOf(b);
if (playing !== 0) return playing;
const priority = priorityOf(a) - priorityOf(b);
if (priority !== 0) return priority;
const aTime = a.timestamp_updated;
const bTime = b.timestamp_updated;
const seconds = (bTime?.secs_since_epoch ?? 0) - (aTime?.secs_since_epoch ?? 0);
if (seconds !== 0) return seconds;
const nanos = (bTime?.nanos_since_epoch ?? 0) - (aTime?.nanos_since_epoch ?? 0);
return nanos !== 0 ? nanos : a.session_id - b.session_id;
});
};

// Insert/replace a session record, evicting any OTHER tracked session that shares its (non-empty)
Expand Down
138 changes: 138 additions & 0 deletions client/src/lib/components/NowPlaying/source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
listen: vi.fn(),
handleInitialize: vi.fn(),
handleUpdate: vi.fn(),
handleDelete: vi.fn()
}));

vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke }));
vi.mock('@tauri-apps/api/event', () => ({ listen: mocks.listen }));
vi.mock('../../../stores/stores', () => ({
handleInitialize: mocks.handleInitialize,
handleUpdate: mocks.handleUpdate,
handleDelete: mocks.handleDelete
}));

import { EVENTS } from '../../bridge/contract';
import type { SessionRecord } from '../../../stores/stores';

type EventCallback = (event: { payload: SessionRecord }) => void;

const record = (sessionId: number): SessionRecord => ({
session_id: sessionId,
source: 'player.exe',
timestamp_created: null,
timestamp_updated: null,
last_media_update: null,
last_model_update: null
});

async function loadSource() {
vi.resetModules();
return import('./source');
}

beforeEach(() => {
vi.clearAllMocks();
});

describe('startMediaSource', () => {
it('attaches every listener first, then initializes and replays deltas received during the snapshot', async () => {
const callbacks = new Map<string, EventCallback>();
mocks.listen.mockImplementation((event: string, callback: EventCallback) => {
callbacks.set(event, callback);
return Promise.resolve(vi.fn());
});
let resolveInitial!: (value: { sessions: Record<number, SessionRecord> }) => void;
mocks.invoke.mockImplementation(
() =>
new Promise<{ sessions: Record<number, SessionRecord> }>((resolve) => {
resolveInitial = resolve;
})
);
const { startMediaSource } = await loadSource();

const starting = startMediaSource();
expect(mocks.listen.mock.calls.map((call) => call[0])).toEqual([
EVENTS.sessionCreate,
EVENTS.sessionUpdate,
EVENTS.sessionDelete
]);
expect(mocks.invoke).not.toHaveBeenCalled();
await Promise.resolve();
await Promise.resolve();
expect(mocks.invoke).toHaveBeenCalledOnce();

callbacks.get(EVENTS.sessionUpdate)!({ payload: record(2) });
expect(mocks.handleUpdate).not.toHaveBeenCalled();
resolveInitial({ sessions: { 1: record(1) } });
await starting;

expect(mocks.handleInitialize).toHaveBeenCalledWith({ sessions: { 1: record(1) } });
expect(mocks.handleUpdate).toHaveBeenCalledWith({ sessionRecord: record(2) });
expect(mocks.handleInitialize.mock.invocationCallOrder[0]).toBeLessThan(
mocks.handleUpdate.mock.invocationCallOrder[0]!
);
});

it('treats create as an upsert and applies live deletes after initialization', async () => {
const callbacks = new Map<string, EventCallback>();
mocks.listen.mockImplementation((event: string, callback: EventCallback) => {
callbacks.set(event, callback);
return Promise.resolve(vi.fn());
});
mocks.invoke.mockResolvedValue({ sessions: {} });
const { startMediaSource } = await loadSource();
await startMediaSource();

callbacks.get(EVENTS.sessionCreate)!({ payload: record(3) });
callbacks.get(EVENTS.sessionDelete)!({ payload: record(3) });
expect(mocks.handleUpdate).toHaveBeenCalledWith({ sessionRecord: record(3) });
expect(mocks.handleDelete).toHaveBeenCalledWith({ sessionRecord: record(3) });
});

it('cleans up partial listeners and allows a retry after listener setup fails', async () => {
const unlistenA = vi.fn();
const unlistenB = vi.fn();
mocks.listen
.mockResolvedValueOnce(unlistenA)
.mockRejectedValueOnce(new Error('event bridge unavailable'))
.mockResolvedValueOnce(unlistenB);
mocks.invoke.mockResolvedValue({ sessions: {} });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const { startMediaSource } = await loadSource();

await startMediaSource();
expect(unlistenA).toHaveBeenCalledOnce();
expect(unlistenB).toHaveBeenCalledOnce();
expect(mocks.invoke).not.toHaveBeenCalled();

mocks.listen.mockResolvedValue(vi.fn());
await startMediaSource();
expect(mocks.listen).toHaveBeenCalledTimes(6);
expect(mocks.invoke).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith('Could not start the media event source', expect.any(Error));
});

it('keeps the live feed when only the initial snapshot fails', async () => {
const callbacks = new Map<string, EventCallback>();
mocks.listen.mockImplementation((event: string, callback: EventCallback) => {
callbacks.set(event, callback);
return Promise.resolve(vi.fn());
});
mocks.invoke.mockRejectedValue(new Error('snapshot unavailable'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const { startMediaSource } = await loadSource();
await startMediaSource();

callbacks.get(EVENTS.sessionUpdate)!({ payload: record(4) });
expect(mocks.handleUpdate).toHaveBeenCalledWith({ sessionRecord: record(4) });
expect(warn).toHaveBeenCalledWith(
'Could not load the initial media sessions',
expect.any(Error)
);
});
});
Loading