Files
Nitro-V3/tests/WiredCreatorTools.helpers.test.ts
T
simoleo89 6793de2106 Set up Vitest + 22 smoke tests on pure modules (proposal #6)
Phase 3 of the refactor plan in docs/ARCHITECTURE.md — the foundation
that unblocks every safe refactor below.

Install
- yarn add -D vitest@3 jsdom @testing-library/dom @testing-library/react
  @testing-library/jest-dom

Note: pinned to vitest@3 (not the latest 4.x) because yarn 1's peer
resolution breaks on vitest@4's peer link to vite. With vitest@3 the
existing Vite 8 install resolves cleanly.

Configuration
- vitest.config.mts (new): separate from vite.config.mjs because the
  dev/build config wires up renderer SDK aliases that point at sibling
  working trees (../renderer, ../Nitro_Render_V3). Tests are written
  against pure modules that don't pull in the renderer, so the test
  runner uses a smaller alias set.
- tests/setup.ts (new): imports @testing-library/jest-dom/vitest so
  custom matchers (toBeInTheDocument, etc.) are available without
  per-file imports.
- tsconfig.json: include "tests" so eslint stops complaining about
  unparseable files; also makes the IDE see the test files.
- package.json scripts: "test" (one-shot) and "test:watch".

Tests
- tests/WiredCreatorTools.helpers.test.ts (18 cases): covers the pure
  helpers extracted in 3c68d97 — createEmptyMonitorSnapshot,
  formatMonitorLatestOccurrence (5 time-bucket branches),
  formatMonitorHistoryOccurrence, formatVariableTimestamp,
  formatMonitorSource (4 branches), normalizeMonitorReason. These are
  the most boring-but-easy-to-break functions; locking them down first
  is high value, near-zero risk.
- tests/navigatorRoomCreatorStore.test.ts (4 cases): exercises the
  Zustand store added in the previous commit — initial state, latch
  semantics, 5s auto-reset (with fake timers), and the
  "second beginCreate restarts the lockout" invariant. Validates that
  the store-based replacement of the let-singleton has the same
  observable behavior, plus the new invariant that wasn't possible
  before (timer composition under StrictMode double-mount).

Side effect: two non-test source files were converted to `import type`
to keep the test bundle from accidentally pulling in the renderer SDK
transitively:
- src/components/wired-tools/WiredCreatorTools.types.ts
  (`import type { AvatarInfoFurni }`)
- src/components/wired-tools/WiredCreatorTools.helpers.ts
  (`import type { HotelDateTimeParts, MonitorSnapshot }`)
This is harmless — TypeScript already treated them as type-only —
and improves tree-shaking on build as a side benefit.

Verification
- yarn test -> 2 files, 22 tests passing in ~1.0s.
- yarn eslint on tests/ + the two type-only-import files: 0 errors,
  0 warnings.

Migration path
- Next adoption targets: cover useDoorbellState reducer (data hook
  split), the new useNitroQuery adapter (timeout/cleanup behavior),
  and the smaller pure formatters under src/api/.
- React component tests (via @testing-library/react) deferred until
  there's a small mock layer for the renderer SDK. The
  @testing-library/* deps are already installed so that PR is
  unblocked.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
2026-05-11 16:31:53 +00:00

150 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
createEmptyMonitorSnapshot,
formatMonitorHistoryOccurrence,
formatMonitorLatestOccurrence,
formatMonitorSource,
formatVariableTimestamp,
normalizeMonitorReason
} from '../src/components/wired-tools/WiredCreatorTools.helpers';
describe('WiredCreatorTools helpers', () =>
{
describe('createEmptyMonitorSnapshot', () =>
{
it('returns a zeroed-out snapshot with empty logs and history arrays', () =>
{
const snap = createEmptyMonitorSnapshot();
expect(snap.usageCurrentWindow).toBe(0);
expect(snap.usageLimitPerWindow).toBe(0);
expect(snap.isHeavy).toBe(false);
expect(snap.killedRemainingSeconds).toBe(0);
expect(snap.logs).toEqual([]);
expect(snap.history).toEqual([]);
});
it('returns fresh arrays each call (no shared state)', () =>
{
const a = createEmptyMonitorSnapshot();
const b = createEmptyMonitorSnapshot();
expect(a.logs).not.toBe(b.logs);
expect(a.history).not.toBe(b.history);
});
});
describe('formatMonitorLatestOccurrence', () =>
{
const NOW = 1_700_000_000_000;
it('returns "/" when no occurrence has been recorded yet', () =>
{
expect(formatMonitorLatestOccurrence(0, NOW)).toBe('/');
expect(formatMonitorLatestOccurrence(-1, NOW)).toBe('/');
});
it('returns "Just now" for diffs under 5 seconds', () =>
{
const occurredAt = NOW / 1000;
expect(formatMonitorLatestOccurrence(occurredAt, NOW)).toBe('Just now');
});
it('returns "<n>s ago" for diffs under a minute', () =>
{
const tenSecondsAgo = (NOW - 10_000) / 1000;
expect(formatMonitorLatestOccurrence(tenSecondsAgo, NOW)).toBe('10s ago');
});
it('returns "<n>m ago" for diffs under an hour', () =>
{
const fiveMinutesAgo = (NOW - 5 * 60 * 1000) / 1000;
expect(formatMonitorLatestOccurrence(fiveMinutesAgo, NOW)).toBe('5m ago');
});
it('returns "<n>h ago" for diffs under a day', () =>
{
const threeHoursAgo = (NOW - 3 * 60 * 60 * 1000) / 1000;
expect(formatMonitorLatestOccurrence(threeHoursAgo, NOW)).toBe('3h ago');
});
it('returns "<n>d ago" for older diffs', () =>
{
const twoDaysAgo = (NOW - 2 * 24 * 60 * 60 * 1000) / 1000;
expect(formatMonitorLatestOccurrence(twoDaysAgo, NOW)).toBe('2d ago');
});
});
describe('formatMonitorHistoryOccurrence', () =>
{
it('returns "/" for non-positive timestamps', () =>
{
expect(formatMonitorHistoryOccurrence(0)).toBe('/');
expect(formatMonitorHistoryOccurrence(-5)).toBe('/');
});
it('returns a non-empty formatted string for a real timestamp', () =>
{
const out = formatMonitorHistoryOccurrence(1_700_000_000);
expect(out).not.toBe('/');
expect(out.length).toBeGreaterThan(0);
});
});
describe('formatVariableTimestamp', () =>
{
it('returns "/" for zero, negative, or falsy values', () =>
{
expect(formatVariableTimestamp(0)).toBe('/');
expect(formatVariableTimestamp(-1)).toBe('/');
expect(formatVariableTimestamp(null)).toBe('/');
});
it('formats a positive epoch-seconds value as a locale string', () =>
{
const out = formatVariableTimestamp(1_700_000_000);
expect(out).not.toBe('/');
expect(out.length).toBeGreaterThan(0);
});
});
describe('formatMonitorSource', () =>
{
it('falls back to "Room monitor" when both label and id are missing', () =>
{
expect(formatMonitorSource('', 0)).toBe('Room monitor');
expect(formatMonitorSource('', -1)).toBe('Room monitor');
});
it('returns just the label when there is no source id', () =>
{
expect(formatMonitorSource('wired-trigger', 0)).toBe('wired-trigger');
});
it('appends "(#<id>)" when source id is positive', () =>
{
expect(formatMonitorSource('on-walk', 42)).toBe('on-walk (#42)');
});
it('uses "wired" as default label when only the id is set', () =>
{
expect(formatMonitorSource('', 7)).toBe('wired (#7)');
});
});
describe('normalizeMonitorReason', () =>
{
it('returns the trimmed reason when one is provided', () =>
{
expect(normalizeMonitorReason(' loop detected ')).toBe('loop detected');
});
it('falls back to a placeholder when the reason is empty or whitespace', () =>
{
expect(normalizeMonitorReason('')).toContain('No detailed reason');
expect(normalizeMonitorReason(' ')).toContain('No detailed reason');
expect(normalizeMonitorReason(null)).toContain('No detailed reason');
});
});
});