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
This commit is contained in:
simoleo89
2026-05-11 16:31:53 +00:00
parent fd1835ca5d
commit 6793de2106
9 changed files with 1208 additions and 14 deletions
+9 -2
View File
@@ -9,7 +9,9 @@
"build": "vite build && node scripts/minify-dist.mjs",
"build:prod": "npx browserslist@latest --update-db && yarn build",
"eslint": "eslint ./src",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@babel/runtime": "^7.29.2",
@@ -35,6 +37,9 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.2.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -47,12 +52,14 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.1",
"postcss": "^8.5.12",
"postcss-nested": "^7.0.2",
"sass": "^1.99.0",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10"
"vite": "^8.0.10",
"vitest": "^3"
}
}
@@ -1,4 +1,4 @@
import { HotelDateTimeParts, MonitorSnapshot } from './WiredCreatorTools.types';
import type { HotelDateTimeParts, MonitorSnapshot } from './WiredCreatorTools.types';
const HOTEL_TIME_FORMATTERS: Map<string, Intl.DateTimeFormat> = new Map();
@@ -1,4 +1,4 @@
import { AvatarInfoFurni } from '../../api';
import type { AvatarInfoFurni } from '../../api';
export type WiredToolsTab = 'monitor' | 'variables' | 'inspection' | 'chests' | 'settings';
export type InspectionElementType = 'furni' | 'user' | 'global';
+149
View File
@@ -0,0 +1,149 @@
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');
});
});
});
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useRoomCreatorStore } from '../src/components/navigator/views/navigatorRoomCreatorStore';
describe('useRoomCreatorStore', () =>
{
beforeEach(() =>
{
vi.useFakeTimers();
useRoomCreatorStore.setState({ isCreating: false });
});
afterEach(() =>
{
vi.useRealTimers();
});
it('starts with isCreating === false', () =>
{
expect(useRoomCreatorStore.getState().isCreating).toBe(false);
});
it('beginCreate() latches isCreating to true', () =>
{
useRoomCreatorStore.getState().beginCreate();
expect(useRoomCreatorStore.getState().isCreating).toBe(true);
});
it('isCreating auto-resets to false after the 5s lockout', () =>
{
useRoomCreatorStore.getState().beginCreate();
expect(useRoomCreatorStore.getState().isCreating).toBe(true);
vi.advanceTimersByTime(4999);
expect(useRoomCreatorStore.getState().isCreating).toBe(true);
vi.advanceTimersByTime(1);
expect(useRoomCreatorStore.getState().isCreating).toBe(false);
});
it('a second beginCreate() resets the lockout timer (no double-fire)', () =>
{
useRoomCreatorStore.getState().beginCreate();
vi.advanceTimersByTime(4000);
// Re-entry restarts the 5s window
useRoomCreatorStore.getState().beginCreate();
// At t=4500 (500ms past the second call), we should still be locked
vi.advanceTimersByTime(500);
expect(useRoomCreatorStore.getState().isCreating).toBe(true);
// Only after another 4500ms (total 5000 since the second call)
vi.advanceTimersByTime(4500);
expect(useRoomCreatorStore.getState().isCreating).toBe(false);
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+1
View File
@@ -30,6 +30,7 @@
},
"include": [
"src",
"tests",
"node_modules/@nitrots/nitro-renderer/src/**/*.ts"
]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
/**
* Test runner config — kept separate from vite.config.mjs because the
* dev/build config wires up the renderer SDK via filesystem aliases that
* point at sibling working trees (`../renderer`, `../Nitro_Render_V3`).
* Tests are deliberately written against pure modules (helpers, stores)
* that don't pull in the renderer.
*/
export default defineConfig({
test: {
environment: 'jsdom',
globals: false,
include: [ 'tests/**/*.test.ts', 'tests/**/*.test.tsx' ],
setupFiles: [ './tests/setup.ts' ],
css: false
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
});
+966 -10
View File
File diff suppressed because it is too large Load Diff