diff --git a/CLAUDE.md b/CLAUDE.md
index 7cfdb23..5d45bc8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -261,7 +261,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` |
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella |
-| Vitest | 113/113 cases on pure helpers + the Zustand store |
+| Vitest | 124/124 cases — 113 on pure helpers + Zustand store, plus the first 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the new renderer-SDK mock at `tests/mocks/renderer-mock.ts` |
| 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 |
@@ -271,7 +271,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 shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) |
-| Wider Vitest coverage (React components) | `@testing-library/*` is installed; needs a small renderer-SDK mock layer first. |
+| 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`. |
## Known open logic bugs
@@ -323,3 +323,11 @@ Fix shapes documented; both are reasonable PRs on their own.
- Asset middleware: `nitroAssetsServer()` in `vite.config.mjs`
- Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()`
before `import('./index')`)
+- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts`
+ (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
+ Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
+ `clearMockEventDispatcher` helpers used by hook tests, the
+ `RoomSessionDoorbellEvent` stub, and a long list of placeholder
+ classes/enums kept around just so the `src/api/*` barrel cascade
+ imports without throwing. **Grow this file when a new test needs a
+ symbol; prefer real deterministic stubs over `vi.fn()`.**
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 7b4d277..57d2cb6 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -484,7 +484,7 @@ Status after this round of work:
- 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`.
-- **113 cases passing** across 8 test files:
+- **124 cases passing** across 10 test files. Pure-module suites:
- `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
factory.
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
@@ -504,13 +504,40 @@ Status after this round of work:
bail-out branches (state-not-AvatarInfoUser, mismatched
user/roomIndex, equal-after-dedup) + the figure / favorite-group
apply paths.
-- **Pure-module convention**: tests live in `tests/` and import from
- concrete file paths (e.g. `../src/api/catalog/CatalogType`) rather
- than the api barrel, so jsdom doesn't transitively load the renderer
- SDK's Pixi-bound modules. Renderer event type imports use
- `import type { … }` so they're erased at compile time and don't
- trigger the runtime module load either.
-- `yarn test` + `yarn test:watch` scripts added.
+
+ Component-/hook-level suites (on the new renderer-SDK mock):
+ - `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render
+ error logged via `NitroLogger.error` + custom fallback +
+ `unknown` default name.
+ - `useDoorbellState.test.tsx` (7) — initial empty state, append on
+ `DOORBELL`, dedup duplicates, remove on `RSDE_ACCEPTED` /
+ `RSDE_REJECTED`, ignore stale events, unsubscribe on unmount.
+
+- **Renderer-SDK mock at `tests/mocks/renderer-mock.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:
+ - Explicit, behavioral stubs for the symbols tests actually
+ exercise: `NitroLogger`, `GetEventDispatcher`,
+ `mockEventDispatcher` / `clearMockEventDispatcher` helpers, the
+ `RoomSessionDoorbellEvent` class (signature mirrors the real
+ `(type, session, userName)` so `tsgo` stays happy).
+ - String-keyed `Proxy` enums for every `*EventType` /
+ `*FigurePartType` / `RoomObjectCategory` etc. — each access
+ returns a stable unique string so dispatch + listener agree.
+ - Lightweight `class StubClass {}` placeholders for the ~30 Pixi
+ and gameplay classes the `src/api/*` barrel touches at import
+ time (`NitroAlphaFilter`, `NitroContainer`, `EventDispatcher`,
+ etc.). Keeps the cascade from throwing without simulating
+ behavior tests don't care about.
+ - Singleton getters (`GetAssetManager`, `GetCommunication`,
+ `GetSessionDataManager`, …) returning a chainable proxy so
+ `GetX().y.z` evaluates to a no-op proxy instead of crashing.
+- **Pure-module convention** (still applies for non-component tests):
+ import from concrete file paths so jsdom doesn't transitively load
+ the renderer SDK; use `import type { … }` for type-only renderer
+ imports.
+- `yarn test` + `yarn test:watch` scripts.
### Logic bug fixes
- Doorbell close button didn't close while users were pending
@@ -648,11 +675,15 @@ Remaining order of value/risk for the next contributor:
token; the `LayoutFurniImageView` / `LayoutAvatarImageView` async
fetch race needs a request-id ref (or is solved by migrating the
image fetch to `useNitroQuery` keyed on props).
-6. **Wider Vitest coverage** — next worthwhile targets: the
- `useNitroQuery` adapter (timeout + cleanup + accept-filter
- behavior, needs a stub for `@nitrots/nitro-renderer`),
- `useDoorbellState`/`useUserChooserState` event-reducer logic
- (needs the same renderer stub).
+6. **Widen the component/hook Vitest coverage.** The renderer-SDK
+ mock layer is in place (`tests/mocks/renderer-mock.ts`) and the
+ first two pilots — `WidgetErrorBoundary` and `useDoorbellState` —
+ pass. Good follow-up targets: other `*State` hooks built on event
+ reducers (`useFurniChooserState`, `useUserChooserState`,
+ `useFriendRequestState`, `useChatInputState`), the `useNitroQuery`
+ adapter (timeout + cleanup + accept-filter behavior), and the
+ `LoginView` Form Actions happy/error paths. Each new test will
+ likely need to add 1-3 named exports to the renderer mock.
Skipped intentionally and documented in commit messages:
diff --git a/tests/WidgetErrorBoundary.test.tsx b/tests/WidgetErrorBoundary.test.tsx
new file mode 100644
index 0000000..3d6b4a9
--- /dev/null
+++ b/tests/WidgetErrorBoundary.test.tsx
@@ -0,0 +1,96 @@
+/* @vitest-environment jsdom */
+
+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 { NitroLogger } from '@nitrots/nitro-renderer'` resolves to
+// `tests/mocks/renderer-mock.ts` via the alias in vitest.config.mts.
+// The SUT imports the same path, so both reach the same vi.fn instance.
+
+describe('WidgetErrorBoundary', () =>
+{
+ beforeEach(() =>
+ {
+ vi.clearAllMocks();
+ // react-error-boundary lets React's "uncaught error" log through
+ // by default — silence it so jsdom doesn't dump a stack trace
+ // every time we deliberately throw below.
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() =>
+ {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders its children when nothing throws', () =>
+ {
+ render(
+
+ visible
+
+ );
+
+ expect(screen.getByTestId('child')).toHaveTextContent('visible');
+ });
+
+ it('swallows a render-time error to a silent fallback and logs it', () =>
+ {
+ const Boom: FC = () =>
+ {
+ throw new Error('kaboom');
+ };
+
+ const { container } = render(
+
+
+
+ );
+
+ // Default fallback is `() => null` → boundary subtree is empty.
+ expect(container).toBeEmptyDOMElement();
+
+ expect(NitroLogger.error).toHaveBeenCalledTimes(1);
+ const [ message, err ] = (NitroLogger.error as ReturnType).mock.calls[0];
+ expect(message).toBe('[Widget:Boom] crashed');
+ expect(err).toBeInstanceOf(Error);
+ expect((err as Error).message).toBe('kaboom');
+ });
+
+ it('renders a custom fallback node when provided', () =>
+ {
+ const Boom: FC = () =>
+ {
+ throw new Error('explode');
+ };
+
+ render(
+ offline }>
+
+
+ );
+
+ expect(screen.getByTestId('fb')).toHaveTextContent('offline');
+ });
+
+ it('uses "unknown" as the widget name when the prop is omitted', () =>
+ {
+ const Boom: FC = () =>
+ {
+ throw new Error('anonymous');
+ };
+
+ render(
+
+
+
+ );
+
+ expect(NitroLogger.error).toHaveBeenCalledTimes(1);
+ expect((NitroLogger.error as ReturnType).mock.calls[0][0]).toBe('[Widget:unknown] crashed');
+ });
+});
diff --git a/tests/mocks/renderer-mock.ts b/tests/mocks/renderer-mock.ts
new file mode 100644
index 0000000..acd410e
--- /dev/null
+++ b/tests/mocks/renderer-mock.ts
@@ -0,0 +1,260 @@
+/**
+ * Stub of `@nitrots/nitro-renderer` for Vitest.
+ *
+ * The real package eagerly loads Pixi v8 + a few hundred Habbo message
+ * parsers/composers at module import time, which jsdom cannot host:
+ * any `tests/**` file that transitively pulls a renderer symbol throws
+ * before a single assertion runs.
+ *
+ * This module replaces it via `resolve.alias` in `vitest.config.mts`.
+ * We provide explicit named exports for the symbols a test currently
+ * needs (logger, event dispatcher, doorbell event class); everything
+ * else mentioned in the comments below is a generic stub kept just to
+ * keep the side-effectful imports happy as `src/api/index.ts` and
+ * friends are pulled in transitively by the barrel cascade.
+ *
+ * Grow this file as new tests require additional symbols. Prefer adding
+ * a real (deterministic) stub over wiring `vi.fn()` — it keeps the
+ * mocks readable and avoids state bleed between cases.
+ */
+
+import { vi } from 'vitest';
+
+// ---------------------------------------------------------------------------
+// Logger
+// ---------------------------------------------------------------------------
+
+export const NitroLogger = {
+ log: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ enableContexts: vi.fn(),
+ setDebug: vi.fn()
+};
+
+// ---------------------------------------------------------------------------
+// Event dispatcher
+// ---------------------------------------------------------------------------
+//
+// `GetEventDispatcher()` in the real SDK returns the renderer-wide event
+// bus. Tests use `mockEventDispatcher.dispatchEvent(event)` to simulate
+// a server push. `clearMockEventDispatcher()` resets the listener map
+// between cases so subscriptions from a previous test don't leak.
+
+type Listener = (event: any) => void;
+
+const listeners = new Map>();
+
+export const mockEventDispatcher = {
+ addEventListener(type: string, handler: Listener)
+ {
+ let bucket = listeners.get(type);
+
+ if(!bucket)
+ {
+ bucket = new Set();
+ listeners.set(type, bucket);
+ }
+
+ bucket.add(handler);
+ },
+ removeEventListener(type: string, handler: Listener)
+ {
+ listeners.get(type)?.delete(handler);
+ },
+ dispatchEvent(event: { type: string })
+ {
+ const bucket = listeners.get(event.type);
+
+ if(!bucket) return;
+
+ for(const handler of bucket) handler(event);
+ },
+ hasListeners(type: string)
+ {
+ return (listeners.get(type)?.size ?? 0) > 0;
+ }
+};
+
+export const clearMockEventDispatcher = () =>
+{
+ listeners.clear();
+};
+
+export const GetEventDispatcher = vi.fn(() => mockEventDispatcher);
+
+// ---------------------------------------------------------------------------
+// Event type enums (string-keyed Proxies)
+// ---------------------------------------------------------------------------
+//
+// The real `*EventType` is a `class { static readonly FOO = '...'; … }`
+// with stable wire strings. Tests only need each constant to be a
+// unique string so dispatch + listener agree.
+
+const makeEnumProxy = (label: string) => new Proxy({}, {
+ get: (_, prop) => typeof prop === 'string' ? `mock:${ label }:${ prop }` : undefined
+}) as Record;
+
+export const NitroEventType = makeEnumProxy('NitroEventType');
+export const MouseEventType = makeEnumProxy('MouseEventType');
+export const TouchEventType = makeEnumProxy('TouchEventType');
+export const RoomObjectCategory = makeEnumProxy('RoomObjectCategory');
+export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource');
+export const RoomObjectType = makeEnumProxy('RoomObjectType');
+export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable');
+export const RoomControllerLevel = makeEnumProxy('RoomControllerLevel');
+export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum');
+export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum');
+export const FurnitureType = makeEnumProxy('FurnitureType');
+export const PetType = makeEnumProxy('PetType');
+export const AvatarFigurePartType = makeEnumProxy('AvatarFigurePartType');
+export const AvatarScaleType = makeEnumProxy('AvatarScaleType');
+export const AvatarSetType = makeEnumProxy('AvatarSetType');
+export const AvatarAction = makeEnumProxy('AvatarAction');
+export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter');
+
+// ---------------------------------------------------------------------------
+// Doorbell event class
+// ---------------------------------------------------------------------------
+
+export class RoomSessionDoorbellEvent
+{
+ // Wire strings copied from the real class so any consumer that
+ // ignores the indirection through the `.DOORBELL` static still
+ // matches.
+ static readonly DOORBELL = 'RSDE_DOORBELL';
+ static readonly RSDE_ACCEPTED = 'RSDE_ACCEPTED';
+ static readonly RSDE_REJECTED = 'RSDE_REJECTED';
+
+ // Mirrors the real constructor signature `(type, session, userName)`
+ // so `tsgo` is happy. Tests can pass `null` for the session: the
+ // SUT only reads `.userName` and `.type`.
+ constructor(public readonly type: string, public readonly _session: unknown, public readonly userName: string) {}
+}
+
+// ---------------------------------------------------------------------------
+// Generic classes — placeholders for symbols that need to exist as
+// constructors so module-level `new X(...)` calls don't crash during
+// the barrel cascade, but whose behavior tests don't yet exercise.
+// ---------------------------------------------------------------------------
+
+class StubClass
+{
+ constructor(..._args: unknown[]) {}
+}
+
+export class NitroAlphaFilter extends StubClass {}
+export class NitroContainer extends StubClass {}
+export class NitroRectangle extends StubClass {}
+export class NitroSprite extends StubClass {}
+export class NitroTexture extends StubClass {}
+export class NitroSoundEvent extends StubClass {}
+export class NitroEvent extends StubClass {}
+export class MessageEvent extends StubClass {}
+export class RoomEngineObjectEvent extends StubClass {}
+export class CreateLinkEvent extends StubClass {}
+export class EventDispatcher extends StubClass {}
+export class AdvancedMap extends StubClass {}
+export class AvatarFigureContainer extends StubClass {}
+export class Vector3d extends StubClass {}
+export class ObjectDataFactory extends StubClass {}
+export class RoomDataParser extends StubClass {}
+export class RoomModerationSettings extends StubClass {}
+export class StringDataType extends StubClass {}
+export class SellablePetPaletteData extends StubClass {}
+export class PetFigureData extends StubClass {}
+export class PetData extends StubClass {}
+export class NodeData extends StubClass {}
+export class ItemDataStructure extends StubClass {}
+export class HabboGroupEntryData extends StubClass {}
+export class FriendParser extends StubClass {}
+export class FriendCategoryData extends StubClass {}
+export class FriendRequestData extends StubClass {}
+export class FurnitureListItemParser extends StubClass {}
+export class BotData extends StubClass {}
+export class AchievementData extends StubClass {}
+export class CatalogPageMessageProductData extends StubClass {}
+export class GiftWrappingConfigurationParser extends StubClass {}
+export class WiredFilter extends StubClass {}
+export class HabboWebTools extends StubClass {}
+
+// Composers — symbol-only constructors; only their identity matters in the
+// codebase ("did the SUT call SendMessageComposer(new FooComposer(args))").
+export class AddFavouriteRoomMessageComposer extends StubClass {}
+export class DeleteFavouriteRoomMessageComposer extends StubClass {}
+export class DesktopViewComposer extends StubClass {}
+export class FurniturePlacePaintComposer extends StubClass {}
+export class GetGuestRoomMessageComposer extends StubClass {}
+export class GetProductOfferComposer extends StubClass {}
+export class GroupFavoriteComposer extends StubClass {}
+export class GroupInformationComposer extends StubClass {}
+export class GroupJoinComposer extends StubClass {}
+export class GroupUnfavoriteComposer extends StubClass {}
+export class UserProfileComposer extends StubClass {}
+
+// `ChooserSelectionFilter` is used as a string enum in some call sites.
+export const ChooserSelectionFilter = makeEnumProxy('ChooserSelectionFilter');
+
+// ---------------------------------------------------------------------------
+// Singleton getters
+// ---------------------------------------------------------------------------
+
+const stubManager = () =>
+{
+ const sentinel = new Proxy(() => undefined, {
+ get(target, prop)
+ {
+ if(prop === 'then') return undefined;
+ const cached = (target as any)[prop];
+ if(cached !== undefined) return cached;
+ // Most fields read from a real manager are either methods
+ // (return functions) or sub-objects (return proxies). We
+ // return another callable proxy so chained access works.
+ const value = stubManager();
+ (target as any)[prop] = value;
+ return value;
+ },
+ apply()
+ {
+ return undefined;
+ }
+ });
+
+ return sentinel;
+};
+
+export const GetAssetManager = vi.fn(stubManager);
+export const GetAvatarRenderManager = vi.fn(stubManager);
+export const GetCommunication = vi.fn(stubManager);
+export const GetConfiguration = vi.fn(stubManager);
+export const GetLocalizationManager = vi.fn(stubManager);
+export const GetRoomEngine = vi.fn(stubManager);
+export const GetRoomSessionManager = vi.fn(stubManager);
+export const GetSessionDataManager = vi.fn(stubManager);
+export const GetTickerTime = vi.fn(() => 0);
+export const TextureUtils = stubManager();
+export const NitroVersion = stubManager();
+
+// ---------------------------------------------------------------------------
+// Type-only re-exports (interfaces erase at compile time, but listing them
+// here documents what the codebase imports through the type channel).
+//
+// IAvatarFigureContainer · IEventDispatcher · IFigurePart · IFigurePartSet ·
+// IFurnitureData · IFurnitureItemData · IGraphicAsset · IMessageComposer ·
+// IMessageEvent · IObjectData · IPartColor · IPollQuestion · IProductData ·
+// IRoomEngine · IRoomModerationSettings · IRoomObject · IRoomObjectController ·
+// IRoomObjectSpriteVisualization · IRoomPetData · IRoomSession · IRoomUserData
+//
+// No need to alias them — TypeScript only consults them in the type
+// system, and the production `tsconfig.json` resolves them against the
+// real renderer via `node_modules/@nitrots/nitro-renderer/src`.
+
+// ---------------------------------------------------------------------------
+// Catch-all
+// ---------------------------------------------------------------------------
+//
+// Anything else still resolves to `undefined`. If a test fails with
+// "X is not a constructor" / "X.SOMETHING is not a function", add the
+// missing symbol above with a real stub. Avoid the temptation to
+// blanket-mock everything — explicit stubs surface intent and let
+// failing tests pinpoint what behavior they actually rely on.
diff --git a/tests/useDoorbellState.test.tsx b/tests/useDoorbellState.test.tsx
new file mode 100644
index 0000000..d04be03
--- /dev/null
+++ b/tests/useDoorbellState.test.tsx
@@ -0,0 +1,102 @@
+/* @vitest-environment jsdom */
+
+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';
+
+// Server push helper — mirrors the renderer wire by emitting the same
+// constants the SUT listens to. The real constructor takes a session
+// reference too; pass null since the SUT only reads `.userName`.
+const dispatchDoorbell = (type: string, userName: string) =>
+{
+ act(() =>
+ {
+ mockEventDispatcher.dispatchEvent(new RoomSessionDoorbellEvent(type, null as any, userName));
+ });
+};
+
+describe('useDoorbellState', () =>
+{
+ beforeEach(() =>
+ {
+ clearMockEventDispatcher();
+ });
+
+ afterEach(() =>
+ {
+ cleanup();
+ });
+
+ it('starts with no users pending', () =>
+ {
+ const { result } = renderHook(() => useDoorbellState());
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('appends the username from a DOORBELL event', () =>
+ {
+ const { result } = renderHook(() => useDoorbellState());
+
+ dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice');
+
+ expect(result.current).toEqual([ 'Alice' ]);
+ });
+
+ it('ignores duplicate DOORBELL events for the same username', () =>
+ {
+ const { result } = renderHook(() => useDoorbellState());
+
+ dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice');
+ dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice');
+
+ expect(result.current).toEqual([ 'Alice' ]);
+ });
+
+ it('removes a user on RSDE_ACCEPTED while keeping the others', () =>
+ {
+ const { result } = renderHook(() => useDoorbellState());
+
+ dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice');
+ dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Bob');
+ dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_ACCEPTED, 'Alice');
+
+ expect(result.current).toEqual([ 'Bob' ]);
+ });
+
+ it('removes a user on RSDE_REJECTED', () =>
+ {
+ const { result } = renderHook(() => useDoorbellState());
+
+ dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Carol');
+ dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_REJECTED, 'Carol');
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('ignores accept/reject events for users that were never pending', () =>
+ {
+ const { result } = renderHook(() => useDoorbellState());
+
+ dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_ACCEPTED, 'Ghost');
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('unsubscribes from all three events on unmount', () =>
+ {
+ const { unmount } = renderHook(() => useDoorbellState());
+
+ expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.DOORBELL)).toBe(true);
+ expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_ACCEPTED)).toBe(true);
+ expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_REJECTED)).toBe(true);
+
+ unmount();
+
+ expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.DOORBELL)).toBe(false);
+ expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_ACCEPTED)).toBe(false);
+ expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_REJECTED)).toBe(false);
+ });
+});
diff --git a/vitest.config.mts b/vitest.config.mts
index b6cddc3..7c217c6 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -5,8 +5,12 @@ 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.
+ *
+ * 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.
*/
export default defineConfig({
test: {
@@ -18,6 +22,7 @@ export default defineConfig({
},
resolve: {
alias: {
+ '@nitrots/nitro-renderer': resolve(__dirname, 'tests/mocks/renderer-mock.ts'),
'@': resolve(__dirname, 'src')
}
}