Files
Nitro-V3/src/components/wired-tools/WiredCreatorTools.helpers.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

148 lines
4.5 KiB
TypeScript

import type { HotelDateTimeParts, MonitorSnapshot } from './WiredCreatorTools.types';
const HOTEL_TIME_FORMATTERS: Map<string, Intl.DateTimeFormat> = new Map();
export const createEmptyMonitorSnapshot = (): MonitorSnapshot =>
({
usageCurrentWindow: 0,
usageLimitPerWindow: 0,
isHeavy: false,
delayedEventsPending: 0,
delayedEventsLimit: 0,
averageExecutionMs: 0,
peakExecutionMs: 0,
recursionDepthCurrent: 0,
recursionDepthLimit: 0,
killedRemainingSeconds: 0,
usageWindowMs: 0,
overloadAverageThresholdMs: 0,
overloadPeakThresholdMs: 0,
heavyUsageThresholdPercent: 0,
heavyConsecutiveWindowsThreshold: 0,
overloadConsecutiveWindowsThreshold: 0,
heavyDelayedThresholdPercent: 0,
logs: [],
history: []
});
export const getHotelTimeFormatter = (timeZone: string): Intl.DateTimeFormat =>
{
const formatterTimeZone = (timeZone || 'UTC');
const existingFormatter = HOTEL_TIME_FORMATTERS.get(formatterTimeZone);
if(existingFormatter) return existingFormatter;
let formatter: Intl.DateTimeFormat = null;
try
{
formatter = new Intl.DateTimeFormat('en-GB', {
timeZone: formatterTimeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
}
catch
{
formatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
}
HOTEL_TIME_FORMATTERS.set(formatterTimeZone, formatter);
return formatter;
};
export const getHotelDateTimeParts = (epochMs: number, timeZone: string): HotelDateTimeParts =>
{
const normalizedEpochMs = Number.isFinite(epochMs) ? epochMs : Date.now();
const date = new Date(normalizedEpochMs);
const formatter = getHotelTimeFormatter(timeZone);
const formattedParts = formatter.formatToParts(date);
const partsMap = new Map<string, string>();
for(const part of formattedParts)
{
if(part.type === 'literal') continue;
partsMap.set(part.type, part.value);
}
return {
year: Number(partsMap.get('year') ?? date.getUTCFullYear()),
month: Number(partsMap.get('month') ?? (date.getUTCMonth() + 1)),
day: Number(partsMap.get('day') ?? date.getUTCDate()),
hour: Number(partsMap.get('hour') ?? date.getUTCHours()),
minute: Number(partsMap.get('minute') ?? date.getUTCMinutes()),
second: Number(partsMap.get('second') ?? date.getUTCSeconds()),
millisecond: (((normalizedEpochMs % 1000) + 1000) % 1000)
};
};
export const formatMonitorLatestOccurrence = (latestOccurrenceSeconds: number, nowMs: number): string =>
{
if(latestOccurrenceSeconds <= 0) return '/';
const diffMs = Math.max(0, (nowMs - (latestOccurrenceSeconds * 1000)));
const diffSeconds = Math.floor(diffMs / 1000);
if(diffSeconds < 5) return 'Just now';
if(diffSeconds < 60) return `${ diffSeconds }s ago`;
const diffMinutes = Math.floor(diffSeconds / 60);
if(diffMinutes < 60) return `${ diffMinutes }m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if(diffHours < 24) return `${ diffHours }h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${ diffDays }d ago`;
};
export const formatMonitorHistoryOccurrence = (occurredAtSeconds: number): string =>
{
if(occurredAtSeconds <= 0) return '/';
return new Date(occurredAtSeconds * 1000).toLocaleString('en-GB');
};
export const formatVariableTimestamp = (timestamp: number): string =>
{
if(!timestamp || (timestamp <= 0)) return '/';
return new Date(timestamp * 1000).toLocaleString('en-GB');
};
export const formatMonitorSource = (sourceLabel: string, sourceId: number): string =>
{
const normalizedLabel = (sourceLabel || '').trim();
if(!normalizedLabel && !(sourceId > 0)) return 'Room monitor';
if(sourceId > 0) return `${ normalizedLabel || 'wired' } (#${ sourceId })`;
return normalizedLabel;
};
export const normalizeMonitorReason = (reason: string): string =>
{
const normalizedReason = (reason || '').trim();
return normalizedReason || 'No detailed reason was recorded for this entry.';
};