tests: co-locate every Vitest suite next to its subject under src/

Eliminate the parallel `tests/` tree. Each `*.test.ts` / `*.test.tsx`
now sits in the same directory as the module it covers, mirroring its
filename (`Foo.ts` ↔ `Foo.test.ts`). The renderer-SDK mock used by
component / hook tests moves to `src/__mocks__/nitro-renderer.ts` and
the Vitest setup file becomes `src/test-setup.ts` — both still wired
through `vitest.config.mts` exactly as before, only the paths changed.

All 13 suites + 178/178 cases still pass. The production build is
unaffected: rollup only follows imports from `src/index.tsx` and never
crosses into `.test.ts` files, so test code is naturally tree-shaken
out of the bundle. `yarn build` output is byte-for-byte the same on
the user-facing chunks.

tsconfig drops the now-redundant `tests` include entry. CLAUDE.md
'Layout convention' replaces the old `tests/` row with three rows
documenting the new co-located convention, the `__mocks__/` directory
and the `test-setup.ts` entry; ARCHITECTURE.md picks up the same
update. The 'DO NOT CHANGE' qualifier on the layout is preserved —
this rewrite IS the change, decided deliberately to make tests a
first-class part of the source tree rather than a sibling project.
This commit is contained in:
simoleo89
2026-05-16 11:35:03 +02:00
parent eb8d87969d
commit 8b4308af16
19 changed files with 47 additions and 46 deletions
+8 -5
View File
@@ -106,7 +106,9 @@ src/hooks/<area>/<feature?>/ → hooks, FLAT files, no per-feature s
src/api/ → cross-cutting helpers (LocalizeText, composers, formatters)
src/common/ → reusable UI primitives + error boundary
src/state/ → Zustand stores (cross-feature only)
tests/ → Vitest suites (mirror filename of subject)
src/**/*.test.{ts,tsx} → Vitest suites co-located next to their subject (e.g. `Foo.ts` + `Foo.test.ts`)
src/__mocks__/ → hand-written renderer-SDK stub for tests (aliased over `@nitrots/nitro-renderer`)
src/test-setup.ts → Vitest setupFiles entry (jest-dom matchers, etc.)
```
When splitting a god-hook the convention is **3 files, all flat in the
@@ -261,7 +263,7 @@ into `configurePreviewServer` so `yarn preview` keeps working.
| God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` |
| God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends`, `catalog` (three-way: `useCatalogData` / `useCatalogUiState` / `useCatalogActions` — all 48 consumers migrated, deprecated `useCatalog` shim removed) |
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella + per-widget wrap on all 13 room widgets and all 20 furniture widgets (so a crash in one widget no longer takes down its siblings) |
| Vitest | 178/178 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters |
| Vitest | 178/178 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `src/__mocks__/nitro-renderer.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. **Tests are co-located** under `src/`, alongside their subject. |
| Form Actions | Login / Register / Forgot (LoginView.tsx) |
| Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating |
@@ -270,7 +272,7 @@ into `configurePreviewServer` so `yarn preview` keeps working.
| Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. |
| Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. |
| Hoist Wired Creator Tools **derived** state to the Zustand slice | UI-only flags are already hoisted (`useWiredCreatorToolsUiStore`). What's left is the event-driven derived state — `selectedFurni` / `selectedUser` / `monitorSnapshot` / `variableHighlightOverlays` — which can only move alongside their listener effects (multi-session refactor). |
| Widen the component / hook test coverage | Mock layer is in place (`tests/mocks/renderer-mock.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. |
| Widen the component / hook test coverage | Mock layer is in place (`src/__mocks__/nitro-renderer.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. |
## Known open logic bugs
@@ -310,7 +312,8 @@ Fix shapes documented; both are reasonable PRs on their own.
- Architecture doc: `docs/ARCHITECTURE.md`
- Test runner config: `vitest.config.mts` (separate from `vite.config.mjs`)
- Test setup: `tests/setup.ts`
- Test setup: `src/test-setup.ts`
- Test convention: co-located under `src/` next to the subject (`src/<path>/Foo.ts``src/<path>/Foo.test.ts`). No separate `tests/` tree.
- React Query adapter: `src/api/nitro-query/createNitroQuery.ts`
- Zustand factory: `src/state/createNitroStore.ts`
- Error boundary: `src/common/error-boundary/WidgetErrorBoundary.tsx`
@@ -333,7 +336,7 @@ Fix shapes documented; both are reasonable PRs on their own.
`useCatalogUiState` / `useCatalogActions` in
`src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated;
deprecated `useCatalog` shim removed)
- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts`
- Renderer-SDK mock for Vitest: `src/__mocks__/nitro-renderer.ts`
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
`clearMockEventDispatcher` helpers used by hook tests, the
+4 -4
View File
@@ -528,7 +528,7 @@ Pure helpers in `useCatalog.helpers.ts`:
visitors) and passes the resulting `visitorCount` into the helper.
`useCatalog.ts` now imports these instead of defining them inline
(net **75 LOC**). Test file `tests/useCatalog.helpers.test.ts` covers
(net **75 LOC**). Co-located test file `src/hooks/catalog/useCatalog.helpers.test.ts` covers
all six helpers with 34 cases (tree depth + offerId mapping,
node lookups including root exclusion, the limit-reached / guild-admin
fallback / visitors-in-room paths of the placement helper, and the
@@ -538,7 +538,7 @@ empty-map / partial-bucket branches of the offer lookup).
- Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom`
configured. Separate `vitest.config.mts` so the runner doesn't drag in
the renderer SDK aliases from `vite.config.mjs`.
- **163 cases passing** across 12 test files. Pure-module suites:
- **178 cases passing** across 13 test files, **co-located under `src/`** next to each subject (no separate `tests/` tree). Pure-module suites:
- `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
factory.
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
@@ -580,7 +580,7 @@ empty-map / partial-bucket branches of the offer lookup).
`DOORBELL`, dedup duplicates, remove on `RSDE_ACCEPTED` /
`RSDE_REJECTED`, ignore stale events, unsubscribe on unmount.
- **Renderer-SDK mock at `tests/mocks/renderer-mock.ts`** —
- **Renderer-SDK mock at `src/__mocks__/nitro-renderer.ts`** —
`vitest.config.mts` aliases `@nitrots/nitro-renderer` over this file
so jsdom-hosted tests never load Pixi or the message
parser/composer registry. The mock exports:
@@ -734,7 +734,7 @@ Remaining order of value/risk for the next contributor:
each tab. A slice at `src/components/wired-tools/wiredToolsStore.ts`
would make each tab subscribe to the keys it needs.
4. **Widen the component/hook Vitest coverage.** The renderer-SDK
mock layer is in place (`tests/mocks/renderer-mock.ts`) and the
mock layer is in place (`src/__mocks__/nitro-renderer.ts`) and the
first two pilots — `WidgetErrorBoundary` and `useDoorbellState`
pass. Good follow-up targets: other `*State` hooks built on event
reducers (`useFurniChooserState`, `useUserChooserState`,
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { dedupeBadges } from '../src/api/avatar/dedupeBadges';
import { dedupeBadges } from './dedupeBadges';
describe('dedupeBadges', () =>
{
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { ColorUtils } from '../src/api/utils/ColorUtils';
import { FixedSizeStack } from '../src/api/utils/FixedSizeStack';
import { LocalizeFormattedNumber } from '../src/api/utils/LocalizeFormattedNumber';
import { ColorUtils } from './ColorUtils';
import { FixedSizeStack } from './FixedSizeStack';
import { LocalizeFormattedNumber } from './LocalizeFormattedNumber';
describe('LocalizeFormattedNumber', () =>
{
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { CloneObject } from '../src/api/utils/CloneObject';
import { ConvertSeconds } from '../src/api/utils/ConvertSeconds';
import { LocalizeShortNumber } from '../src/api/utils/LocalizeShortNumber';
import { GetWiredTimeLocale } from '../src/api/wired/GetWiredTimeLocale';
import { WiredDateToString } from '../src/api/wired/WiredDateToString';
import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from '../src/api/utils/PrefixUtils';
import { CloneObject } from './CloneObject';
import { ConvertSeconds } from './ConvertSeconds';
import { LocalizeShortNumber } from './LocalizeShortNumber';
import { GetWiredTimeLocale } from '../wired/GetWiredTimeLocale';
import { WiredDateToString } from '../wired/WiredDateToString';
import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from './PrefixUtils';
describe('ConvertSeconds', () =>
{
@@ -5,12 +5,12 @@ import { describe, expect, it, vi } from 'vitest';
* with a deterministic stub. The stub returns `key|amount` so each test
* can assert both the bucket FriendlyTime chose AND the value it computed.
*/
vi.mock('../src/api/utils/LocalizeText', () => ({
vi.mock('./LocalizeText', () => ({
LocalizeText: (key: string, _params?: string[], replacements?: string[]) =>
`${ key }|${ replacements?.[0] ?? '' }`
}));
import { FriendlyTime } from '../src/api/utils/FriendlyTime';
import { FriendlyTime } from './FriendlyTime';
const MINUTE = 60;
const HOUR = 60 * MINUTE;
@@ -4,10 +4,10 @@ import { NitroLogger } from '@nitrots/nitro-renderer';
import { cleanup, render, screen } from '@testing-library/react';
import { FC } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WidgetErrorBoundary } from '../src/common/error-boundary/WidgetErrorBoundary';
import { WidgetErrorBoundary } from './WidgetErrorBoundary';
// `import { NitroLogger } from '@nitrots/nitro-renderer'` resolves to
// `tests/mocks/renderer-mock.ts` via the alias in vitest.config.mts.
// `src/__mocks__/nitro-renderer.ts` via the alias in vitest.config.mts.
// The SUT imports the same path, so both reach the same vi.fn instance.
describe('WidgetErrorBoundary', () =>
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useRoomCreatorStore } from '../src/components/navigator/views/navigatorRoomCreatorStore';
import { useRoomCreatorStore } from './navigatorRoomCreatorStore';
describe('useRoomCreatorStore', () =>
{
@@ -6,7 +6,7 @@ import {
formatMonitorSource,
formatVariableTimestamp,
normalizeMonitorReason
} from '../src/components/wired-tools/WiredCreatorTools.helpers';
} from './WiredCreatorTools.helpers';
describe('WiredCreatorTools helpers', () =>
{
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useWiredCreatorToolsUiStore } from '../src/components/wired-tools/wiredCreatorToolsUiStore';
import { useWiredCreatorToolsUiStore } from './wiredCreatorToolsUiStore';
const INITIAL = {
isVisible: false,
@@ -75,7 +75,7 @@ vi.mock('use-between', () => ({
// Import AFTER the mock is set up. The hooks resolve `useBetween` at
// import time via the module graph, so the order matters.
import { useCatalogActions, useCatalogData, useCatalogUiState } from '../src/hooks/catalog/useCatalog';
import { useCatalogActions, useCatalogData, useCatalogUiState } from './useCatalog';
describe('useCatalog filter contract', () =>
{
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { BuilderFurniPlaceableStatus } from '../src/api/catalog/BuilderFurniPlaceableStatus';
import { CatalogType } from '../src/api/catalog/CatalogType';
import { BuilderFurniPlaceableStatus } from '../../api/catalog/BuilderFurniPlaceableStatus';
import { CatalogType } from '../../api/catalog/CatalogType';
import {
buildCatalogNodeTree,
findNodeById,
@@ -9,7 +9,7 @@ import {
getOfferProductKeys,
normalizeCatalogType,
resolveBuilderFurniPlaceableStatus
} from '../src/hooks/catalog/useCatalog.helpers';
} from './useCatalog.helpers';
// ---------------------------------------------------------------------------
// normalizeCatalogType
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { CatalogType } from '../src/api/catalog/CatalogType';
import { getOffersStorageKey, getPagesStorageKey, normalizeCatalogType, parseOffers, parsePages, STORAGE_KEY_OFFERS_BUILDER, STORAGE_KEY_OFFERS_NORMAL, STORAGE_KEY_PAGES_BUILDER, STORAGE_KEY_PAGES_NORMAL } from '../src/hooks/catalog/useCatalogFavorites.helpers';
import { CatalogType } from '../../api/catalog/CatalogType';
import { getOffersStorageKey, getPagesStorageKey, normalizeCatalogType, parseOffers, parsePages, STORAGE_KEY_OFFERS_BUILDER, STORAGE_KEY_OFFERS_NORMAL, STORAGE_KEY_PAGES_BUILDER, STORAGE_KEY_PAGES_NORMAL } from './useCatalogFavorites.helpers';
describe('normalizeCatalogType', () =>
{
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { AvatarInfoUser } from '../src/api/room/widgets/AvatarInfoUser';
import type { IAvatarInfo } from '../src/api/room/widgets/IAvatarInfo';
import { applyFavouriteGroupUpdate, applyUserBadgesUpdate, applyUserFigureUpdate } from '../src/hooks/rooms/widgets/avatarInfo.reducers';
import { AvatarInfoUser } from '../../../api/room/widgets/AvatarInfoUser';
import type { IAvatarInfo } from '../../../api/room/widgets/IAvatarInfo';
import { applyFavouriteGroupUpdate, applyUserBadgesUpdate, applyUserFigureUpdate } from './avatarInfo.reducers';
/**
* Pure reducers for the InfoStand pilot. They take the inspected
@@ -3,8 +3,8 @@
import { RoomSessionDoorbellEvent } from '@nitrots/nitro-renderer';
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useDoorbellState } from '../src/hooks/rooms/widgets/useDoorbellState';
import { clearMockEventDispatcher, mockEventDispatcher } from './mocks/renderer-mock';
import { useDoorbellState } from './useDoorbellState';
import { clearMockEventDispatcher, mockEventDispatcher } from '../../../__mocks__/nitro-renderer';
// Server push helper — mirrors the renderer wire by emitting the same
// constants the SUT listens to. The real constructor takes a session
-1
View File
@@ -30,7 +30,6 @@
},
"include": [
"src",
"tests",
"node_modules/@nitrots/nitro-renderer/src/**/*.ts"
]
}
+7 -8
View File
@@ -6,23 +6,22 @@ import { resolve } from 'path';
* dev/build config wires up the renderer SDK via filesystem aliases that
* point at sibling working trees (`../renderer`, `../Nitro_Render_V3`).
*
* Test files were originally written against pure modules (helpers,
* stores) that don't pull in the renderer. We now also support
* component-level tests by aliasing `@nitrots/nitro-renderer` to a
* hand-written stub at `tests/mocks/renderer-mock.ts` so jsdom doesn't
* try to evaluate Pixi + the full message parser/composer registry.
* Tests live next to their subject under `src/` (`foo.ts` + `foo.test.ts`).
* The renderer SDK is aliased to a hand-written stub at
* `src/__mocks__/nitro-renderer.ts` so jsdom doesn't try to evaluate
* Pixi + the full message parser/composer registry at import time.
*/
export default defineConfig({
test: {
environment: 'jsdom',
globals: false,
include: [ 'tests/**/*.test.ts', 'tests/**/*.test.tsx' ],
setupFiles: [ './tests/setup.ts' ],
include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx' ],
setupFiles: [ './src/test-setup.ts' ],
css: false
},
resolve: {
alias: {
'@nitrots/nitro-renderer': resolve(__dirname, 'tests/mocks/renderer-mock.ts'),
'@nitrots/nitro-renderer': resolve(__dirname, 'src/__mocks__/nitro-renderer.ts'),
'@': resolve(__dirname, 'src')
}
}