mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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:
+9
-2
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests",
|
||||
"node_modules/@nitrots/nitro-renderer/src/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user