Commit Graph

10 Commits

Author SHA1 Message Date
simoleo89 59d6c4cab3 catalog: three-way singleton-filter split + first 3 consumer migrations
Completes the useCatalog decomposition. After the previous commit
extracted the pure helpers, this one splits the singleton-via-useBetween
store into three slice-specific entry points and migrates a handful of
consumers as proof.

`src/hooks/catalog/useCatalog.ts`

- Internal `useCatalogState` → renamed to `useCatalogStore` and is no
  longer exported. The full return shape is unchanged so callers that
  still go through the shim see the exact same object.
- Three new exports built on top of the same `useBetween` instance:
    - `useCatalogData()` — server-driven read-only slice (rootNode,
      offersToNodes, currentPage, currentOffer, frontPageItems,
      searchResult, roomPreviewer, isBusy, catalog localization
      version, Builders Club counters + timers).
    - `useCatalogUiState()` — UI ephemeral state + writers
      (isVisible, pageId, previousPageId, currentType, activeNodes,
      navigationHidden, purchaseOptions, catalogPlaceMultipleObjects,
      plus every `set*` writer including the ones that mutate the
      data slice on user-driven selection).
    - `useCatalogActions()` — imperative operations only
      (openCatalogByType, toggleCatalogByType, activateNode,
      openPageBy{Id,Name,OfferId}, requestOfferToMover,
      selectCatalogOffer, getNodeBy{Id,Name},
      getBuilderFurniPlaceableStatus).
- `useCatalog` is kept as a deprecated shim that returns the full
  historical surface, so the 48 existing consumers compile and run
  unchanged.

Pilot consumer migrations (3 of 48):

- `CatalogBuildersClubStatusView` — Data (furni counters, seconds
  timers) + UiState (currentType).
- `CatalogBreadcrumbView` — UiState (activeNodes) + Actions
  (activateNode).
- `CatalogNavigationItemView` — UiState (currentType) + Actions
  (activateNode).

Tests: `tests/useCatalog.filters.test.tsx` (5 cases).

`useBetween` is mocked via `vi.hoisted` so the four hooks share one
deterministic fake store — rendering the real `useCatalogStore`
would mount ~30 useState calls + open a fresh RoomPreviewer +
subscribe to a dozen renderer events, which is more than these
contract tests need.

- `useCatalogData` exposes exactly its read-only keys.
- `useCatalogUiState` exposes exactly its UI keys + setters.
- `useCatalogActions` exposes exactly its imperative ops (and
  explicitly NOT data fields — proves no leak across slices).
- Singleton identity: callbacks read through the shim are `===` to
  the ones read through the slices.
- Shim surface: the historical key set is still present so
  un-migrated consumers don't silently break.

Suite: 163/163 (was 158/158). `yarn typecheck` green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:50:56 +02:00
simoleo89 fd3ef7875d catalog: extract pure helpers + 34 cases, consume them from useCatalog
First half of the proposed `useCatalog` decomposition. The 1036-line
god-hook still owns the singleton-via-useBetween, but the pure logic
it used to define inline now lives in a dependency-free module so it
can be tested in isolation and reused by future split-out hooks
(`useCatalogData` / `useCatalogUiState` / `useCatalogActions` when
those land).

New module: `src/hooks/catalog/useCatalog.helpers.ts` (222 LOC).

- `normalizeCatalogType(type?)` — coerce the optional catalog type to
  `NORMAL` / `BUILDER`. Was a 5-line `useCallback` with an empty
  dependency array.
- `getOfferProductKeys(offer)` — produces the canonical
  `productType:id:classId` and `productType:class:className` keys
  for the resolved-offer cache.
- `findNodeById` / `findNodeByName` — DFS over the catalog tree,
  root explicitly excluded so callers can't select the synthetic
  root by mistake.
- `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — extracted
  from the closed-over `getNodesByOfferId`. The `onlyVisible`
  fallback to the full bucket when nothing visible remains is
  preserved.
- `buildCatalogNodeTree(NodeData)` — pulled out of the
  `CatalogPagesListEvent` reducer. Builds the tree and the offerId
  index in one pass; the caller now does `const { rootNode,
  offersToNodes } = buildCatalogNodeTree(parser.root)` instead of
  carrying an inline recursive walker + a local map.
- `resolveBuilderFurniPlaceableStatus(input)` — the placement
  decision tree as a pure function. The hook keeps the
  `GetRoomEngine` / `GetSessionDataManager` reads that count
  non-self, non-moderator visitors (only when the subscription has
  expired) and forwards the resulting `visitorCount` into the
  helper, so the previous early-exit semantics are preserved.

`useCatalog.ts` now imports these and removes ~140 lines of inline
copies. Net hook size: 1036 → 961 LOC. Behavior unchanged.

Tests: `tests/useCatalog.helpers.test.ts` (34 cases).

- `normalizeCatalogType` (4) — BUILDER pass-through, NORMAL
  pass-through, undefined/empty fallback, unknown string fallback.
- `getOfferProductKeys` (5) — both keys, id-only when classId<0,
  class-only when className empty, no-product short-circuit,
  empty productType short-circuit.
- `findNodeById` (5) — null input, root exclusion, immediate child,
  grandchild, miss returns null.
- `findNodeByName` (2) — match by name + root exclusion, miss.
- `getNodesByOfferIdFromMap` (5) — empty map, raw bucket pass-through,
  visible-only filter, fallback when no visible remain, miss.
- `buildCatalogNodeTree` (3) — root depth=0 + empty offer map for a
  leaf-only root, DFS traversal tracks offer→nodes across branch
  and leaf, child.parent === root.
- `resolveBuilderFurniPlaceableStatus` (10) — missing offer,
  not-in-room, owner happy path, non-owner without fallback,
  guild admin with time, furni limit reached, shared-pool override
  ignoring the limit, expired+blocked-by-visitors flag,
  expired+visitor count > 0, expired+empty room is okay.

To support the placement-status test the renderer mock gains real
numeric values for `RoomControllerLevel` (NONE..MODERATOR) and
`RoomObjectCategory` (MINIMUM..MAXIMUM); the previous string-keyed
Proxy stubs made `controllerLevel >= GUILD_ADMIN` evaluate to NaN.

Suite: 158/158 (was 124/124). `yarn typecheck` green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:42:04 +02:00
simoleo89 c4018392f9 tests: add renderer-SDK mock layer + first 2 component-/hook-level pilots
Foundations for widening Vitest coverage past the pure-helper subset.

The real `@nitrots/nitro-renderer` eagerly loads Pixi v8 and the full
Habbo message parser/composer registry at module-import time, which
jsdom cannot host: any `tests/**` file that transitively pulled a
renderer symbol would throw before a single assertion ran. That's
why the existing 8 suites all stuck to pure modules imported by
concrete path and used `import type` for renderer-side names.

Add a stub at `tests/mocks/renderer-mock.ts`, aliased over the package
via `vitest.config.mts`. It exports:

- Explicit behavioral stubs for the symbols tests actually exercise:
  `NitroLogger`, `GetEventDispatcher`, the `mockEventDispatcher`
  helper with `addEventListener` / `removeEventListener` /
  `dispatchEvent` / `hasListeners`, and `RoomSessionDoorbellEvent`
  (signature matches the real `(type, session, userName)` to keep
  tsgo happy).
- String-keyed Proxy enums for `NitroEventType`, `RoomObjectCategory`,
  `AvatarFigurePartType`, etc. — each access returns a stable unique
  string so dispatch and listener agree.
- Lightweight `class StubClass {}` placeholders for the ~30 Pixi and
  gameplay classes the `src/api/*` barrel touches at import time
  (`NitroAlphaFilter`, `NitroContainer`, `EventDispatcher`, …).
  Keeps the cascade from throwing without simulating behavior tests
  don't care about.
- Singleton getters (`GetAssetManager`, `GetCommunication`,
  `GetSessionDataManager`, …) returning a chainable Proxy so deeply
  nested `GetX().y.z(…)` access evaluates to no-op proxies.

Pilots on top of that layer (each one designed to catch a different
class of regression):

- `tests/WidgetErrorBoundary.test.tsx` (4 cases) — happy path,
  default silent fallback + `NitroLogger.error` call, custom
  fallback node, default `unknown` widget name.
- `tests/useDoorbellState.test.tsx` (7 cases) — initial empty state,
  append on `RSDE_DOORBELL`, dedup duplicate names, remove on
  `RSDE_ACCEPTED` / `RSDE_REJECTED`, ignore stale events for
  never-pending users, full unsubscribe on unmount.

Suite count now 124/124 across 10 files (was 113/113 across 8).
`yarn typecheck` still green.

Docs: CLAUDE.md's Vitest row and "Where everything lives" pointer
updated; `docs/ARCHITECTURE.md` Tests section now lists the new
suites + a description of what the mock layer covers, and the
"Wider Vitest coverage" entry in the next-steps list is reframed
from "needs a renderer mock" to "pick the next adopter".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:31:08 +02:00
simoleo89 3c732f1c1a Vitest +14 cases on avatarInfo reducers
The three reducers that drive the InfoStand pilot
(applyUserBadgesUpdate / applyUserFigureUpdate /
applyFavouriteGroupUpdate, in src/hooks/rooms/widgets/avatarInfo.reducers.ts)
have been live for ~10 commits without coverage. They encode
non-trivial branches: 'state not AvatarInfoUser' bail-out,
'event for different user / roomIndex' bail-out, dedup-equality
bail-out, and the clearGroup logic (status === -1 || habboGroupId <= 0).
Add tests pinning every branch.

Two import-tightening tweaks made the reducer module itself
testable in jsdom without dragging the renderer SDK in:

- Renderer event types are now type-only imports — they're erased
  at compile time, so the runtime module load of @nitrots/nitro-renderer
  is skipped. The reducer body only reads plain event fields (no
  ) so this is safe.
- AvatarInfoUser / dedupeBadges / IAvatarInfo come from concrete file
  paths instead of '../../../api' (the barrel pulls in Pixi-bound
  modules via the renderer side-imports).

Tests cover each branch by constructing AvatarInfoUser via the
actual class (so the instanceof guard hits) and casting plain event
objects through  for the typed parameter.

Net Vitest count: 99 -> 113 (8 test files).
2026-05-11 23:04:52 +02:00
simoleo89 8b79233059 Extract useCatalogFavorites pure helpers + 16 Vitest cases
The 5 pure functions inside useCatalogFavorites
(normalizeCatalogType, getOffersStorageKey, getPagesStorageKey,
parseOffers, parsePages) handle the v2 -> v3 storage-key migration
that runs once per user the first time they open the v3 client. The
parseOffers branch in particular silently morphs the legacy number[]
shape into IFavoriteOffer[] — exactly the kind of one-shot migration
code that should have coverage so a refactor doesn't break old saves.

Move them into useCatalogFavorites.helpers.ts (sibling file, matching
the WiredCreatorTools / useInventoryFurni.reducers / avatarInfo.reducers
convention). useCatalogFavorites imports them back, plus re-exports
the IFavoriteOffer type from the helper module for the public API.

Both helpers import CatalogType from the concrete file path
('../../api/catalog/CatalogType') rather than the api barrel, so the
test file doesn't drag in the renderer SDK and run aground in jsdom.

Tests cover:
- normalizeCatalogType fallback to NORMAL on undefined/garbage/explicit
- storage-key routing for NORMAL / BUILDER / missing arg
- parseOffers: invalid JSON, non-array, empty array, v2 number[] migration,
  v3 IFavoriteOffer[] passthrough, mixed-array passthrough
- parsePages: invalid JSON, non-array, normal array

Net Vitest count: 83 -> 99 (7 test files).
2026-05-11 22:45:57 +02:00
simoleo89 b1729d8ddc Vitest: cover dedupeBadges with 6 cases
The badge-deduplication helper was extracted from
InfoStandWidgetUserView in the prior commit; it's a pure (badges[]) =>
badges[] function that keeps slot indices stable by replacing duplicate
codes with empty strings. Coverage:

- empty input
- unique-only passthrough
- duplicate-replaced-with-empty
- falsy entries (null / undefined / '') normalized to ''
- first-occurrence-wins semantics
- order sensitivity (same multiset, different order -> different output)
2026-05-11 21:11:02 +02:00
simoleo89 dbafc97e89 Drop unused login dialogs (dead code) + Vitest coverage on FriendlyTime
Two unrelated cleanups grouped because they're both small and safe.

Dead code removal
- src/components/login/components/RegisterDialog.tsx
- src/components/login/components/ForgotDialog.tsx
- src/components/login/components/shared.ts (only consumed by the two
  dialogs above)

These were the older non-Form-Actions versions of the register and
forgot-password dialogs. LoginView.tsx defines its own inline versions
that use `useActionState` + `useFormStatus` (Phase 3 of the React 19
modernization), which are the ones actually rendered. The legacy
files were already documented as dead in docs/ARCHITECTURE.md.

NewsWindow.tsx and the `components/` directory itself stay — NewsWindow
is still imported by LoginView at the bottom of the login flow.

Vitest coverage on FriendlyTime (+12 cases)
- 65 -> 77 passing tests, 5 -> 6 test files.
- LocalizeText is mocked with a deterministic stub
  (`${ key }|${ amount }`) so each assertion can verify both the bucket
  chosen and the rounded amount. The mock also short-circuits the
  transitive renderer-SDK import, which keeps the test runner
  decoupled from the renderer install state.
- Buckets covered: seconds / minutes / hours / days / months / years
  for both `format` and `shortFormat`. Plus: threshold override,
  key-suffix concatenation, half-hour rounding, the raw
  `getLocalization` helper.

Verification
- yarn test: 6 files / 77 cases / ~2s.
- yarn eslint on the new test file: 0 errors / 0 warnings.
- yarn tsc: clean on touched files.
2026-05-11 17:59:46 +00:00
simoleo89 bb28d252d8 Vitest: +16 cases on ColorUtils, FixedSizeStack, LocalizeFormattedNumber
49 -> 65 passing tests, 4 -> 5 test files.

New file: tests/api-utils-extra.test.ts (16 cases)
- LocalizeFormattedNumber (3): zero/NaN/null guard, sub-1000 stays,
  >=1000 inserts thin-space group separators.
- ColorUtils (8): makeColorHex, makeColorNumberHex (with zero-pad),
  convertFromHex (with/without #), int_to_8BitVals/eight_bitVals_to_int
  roundtrip, int2rgb pure-RGB output, zero-input edge cases.
- FixedSizeStack (4): grow then overwrite oldest (ring-buffer
  semantics), reset clears state, partial-fill behavior of getMax,
  empty-stack returns Number.MIN_VALUE. The "partial-fill" case
  documents a subtle quirk: getMax iterates the whole maxSize window
  including undefined slots, but `undefined > X` is false in JS so
  the inserted value wins — the test pins that behavior.

Note on `usePetPackageWidget` and `useWordQuizWidget`
- They were both considered for a state/actions split this turn but
  their actions mutate internal state (`onClose` resets 5 useState,
  `vote` reads pollId/question/answerSent). A clean split would
  require either passing args to the action or hoisting the state
  to a shared store first. Deferred as follow-up.

Verification
- yarn test: 5 files / 65 cases / ~1.9s.
- yarn eslint on the new test file: 0 errors / 0 warnings.
2026-05-11 17:50:47 +00:00
simoleo89 9d2e4a7324 Expand Vitest coverage on the pure helpers in src/api/{utils,wired}
22 -> 49 passing tests, 2 -> 3 test files.

Targets are functions with zero external dependencies (no renderer SDK,
no network, no DOM). They were picked because:
- they're easy to break by accident in a refactor (rounding edge cases,
  zero-padding rules);
- their behavior is documented by tests once and for all, including the
  surprising bit about LocalizeShortNumber rounding 950..999 into the
  "1K" bucket (kept as an explicit "documented quirk" assertion rather
  than fixed — the current behavior is what the rest of the app
  expects).

New file: tests/api-utils.test.ts (27 cases)
- ConvertSeconds: zero, 1m / 1h / 1d, mixed, single-digit padding (6).
- LocalizeShortNumber: zero/NaN/null guard, sub-1000 stays as-is, K/M/B
  buckets, negative numbers, the 950..999 rounding quirk (7).
- CloneObject: primitives, identity preservation, key fidelity (3).
- GetWiredTimeLocale: even (whole sec), odd (half sec), zero (3).
- WiredDateToString: zero-pad rules, two-digit values (2).
- PrefixUtils.parsePrefixColors: empty inputs, mapping, color reuse (3).
- PrefixUtils.getPrefixFontStyle: default empty id, known preset,
  unknown id (3).

Verification
- yarn test: 3 files / 49 cases / ~1.1s.
- yarn eslint on tests/: 0 errors / 0 warnings.
- All test targets are stable pure functions; the assertions
  double as documentation for callers.
2026-05-11 16:38:12 +00:00
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