mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
6793de2106
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
148 lines
4.5 KiB
TypeScript
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.';
|
|
};
|