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>
This commit is contained in:
simoleo89
2026-05-13 21:42:04 +02:00
parent c4018392f9
commit fd3ef7875d
6 changed files with 714 additions and 128 deletions
+36 -1
View File
@@ -480,11 +480,39 @@ Status after this round of work:
| CatalogPagesList / CatalogPage | **deferred** — core state slice (rootNode / offersToNodes / currentPage), needs its own split-out store |
| BuildersClubFurniCount / SubscriptionStatus | **deferred** — read by the internal `getBuilderFurniPlaceableStatus` logic, moves with the data/actions split |
Pure-helper extraction landed before the singleton split:
`src/hooks/catalog/useCatalog.helpers.ts` hosts the dependency-free
pieces previously inlined in the hook —
- `normalizeCatalogType(type?)` — coerce the optional catalog type
back to `NORMAL` / `BUILDER`.
- `getOfferProductKeys(offer)` — canonical lookup keys for the
resolved-offer cache.
- `findNodeById` / `findNodeByName` — DFS over the catalog tree,
root excluded.
- `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — used to be
the closed-over `getNodesByOfferId`; the `onlyVisible` fallback to
the full bucket is preserved.
- `buildCatalogNodeTree(NodeData)` — pulled out of the
`CatalogPagesListEvent` reducer; returns the tree + the offerId
index map in one pass.
- `resolveBuilderFurniPlaceableStatus(input)` — the placement
decision tree as a pure function; the hook keeps the `GetRoomEngine`
/ `GetSessionDataManager` reads (to count non-self, non-moderator
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
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
empty-map / partial-bucket branches of the offer lookup).
### Tests
- 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`.
- **124 cases passing** across 10 test files. Pure-module suites:
- **158 cases passing** across 11 test files. Pure-module suites:
- `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
factory.
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
@@ -504,6 +532,13 @@ Status after this round of work:
bail-out branches (state-not-AvatarInfoUser, mismatched
user/roomIndex, equal-after-dedup) + the figure / favorite-group
apply paths.
- `useCatalog.helpers.test.ts` (34) — catalog pure helpers
extracted out of the god-hook: `normalizeCatalogType`,
`getOfferProductKeys`, `findNodeById` / `findNodeByName` (with
the root-exclusion guard), `getNodesByOfferIdFromMap` (with
the partial-visible fallback), `buildCatalogNodeTree` (tree
depth + offerId index), and the full decision tree of
`resolveBuilderFurniPlaceableStatus`.
Component-/hook-level suites (on the new renderer-SDK mock):
- `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render