From fd3ef7875d8e28b03c8739a0c9dd2412710b8382 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:42:04 +0200 Subject: [PATCH] catalog: extract pure helpers + 34 cases, consume them from useCatalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 8 +- docs/ARCHITECTURE.md | 37 ++- src/hooks/catalog/useCatalog.helpers.ts | 222 ++++++++++++++ src/hooks/catalog/useCatalog.ts | 171 +++-------- tests/mocks/renderer-mock.ts | 25 +- tests/useCatalog.helpers.test.ts | 379 ++++++++++++++++++++++++ 6 files changed, 714 insertions(+), 128 deletions(-) create mode 100644 src/hooks/catalog/useCatalog.helpers.ts create mode 100644 tests/useCatalog.helpers.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5d45bc8..20b8ee8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,13 +261,13 @@ 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 | 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` | +| Vitest | 158/158 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, plus 34 cases on the freshly extracted catalog helpers | | 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 | | Not yet | Notes | |---|---| -| Core `useCatalog` split | Session-stable secondary fetches all migrated to TanStack queries (see ARCHITECTURE.md). What's left: core `rootNode`/`offersToNodes`/`currentPage` slice + Builders Club status. Needs a dedicated `useCatalogData`/`useCatalogUiState`/`useCatalogActions` split. | +| Singleton-filter split of `useCatalog` | Pure helpers extracted to `useCatalog.helpers.ts` and consumed in the hook (`buildCatalogNodeTree`, `findNodeById`, `findNodeByName`, `getNodesByOfferIdFromMap`, `getOfferProductKeys`, `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`). What still remains: split the singleton state into `useCatalogData` / `useCatalogUiState` / `useCatalogActions` filters via `useBetween`, mirroring the wired-tools / translation / notification / friends pattern. The 48 consumers can stay on the shim during the transition. | | 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.) | @@ -323,6 +323,10 @@ 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')`) +- Catalog pure helpers: `src/hooks/catalog/useCatalog.helpers.ts` + (`buildCatalogNodeTree`, `findNodeById` / `findNodeByName`, + `getNodesByOfferIdFromMap`, `getOfferProductKeys`, + `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`) - 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` / diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57d2cb6..345c0e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/src/hooks/catalog/useCatalog.helpers.ts b/src/hooks/catalog/useCatalog.helpers.ts new file mode 100644 index 0000000..ac78527 --- /dev/null +++ b/src/hooks/catalog/useCatalog.helpers.ts @@ -0,0 +1,222 @@ +import { NodeData, RoomControllerLevel, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer'; +import { BuilderFurniPlaceableStatus, CatalogNode, CatalogType, ICatalogNode, IPurchasableOffer } from '../../api'; + +/** + * Pure helpers extracted from `useCatalog.ts`. Each function takes the + * relevant pieces of state as inputs (instead of closing over them via + * useCallback) so it can be unit-tested without rendering a React tree. + * + * Keep these dependency-free at the React layer: no `useState`, no + * refs, no `vi.fn`-able side effects beyond what the renderer SDK + * already exposes (`GetRoomEngine`, etc., which the call sites guard). + */ + +/** + * The catalog has two top-level "types" — the regular catalog and the + * Builders Club catalog. Anything else maps to NORMAL. Centralising + * the coercion in one place keeps the switch from drifting between + * call sites and the message-event handlers. + */ +export const normalizeCatalogType = (type?: string): string => +{ + if(type === CatalogType.BUILDER) return CatalogType.BUILDER; + + return CatalogType.NORMAL; +}; + +/** + * Build the canonical product-key list for a purchasable offer. Used + * by the resolved-offer cache so the same offer can be looked up by + * either `productType:id:classId` or `productType:class:className`. + */ +export const getOfferProductKeys = (offer: IPurchasableOffer | null | undefined): string[] => +{ + const keys: string[] = []; + const product = offer?.product; + + if(!product) return keys; + + if(product.productType && (product.productClassId >= 0)) + { + keys.push(`${ product.productType }:id:${ product.productClassId }`); + } + + if(product.productType && product.furnitureData?.className?.length) + { + keys.push(`${ product.productType }:class:${ product.furnitureData.className }`); + } + + return keys; +}; + +/** + * Depth-first search by pageId. The root is excluded so callers never + * select the synthetic "root" node by mistake. Recursive but bounded + * by the tree the server sends (typically 3-4 levels deep). + */ +export const findNodeById = (id: number, node: ICatalogNode | null, rootNode: ICatalogNode | null): ICatalogNode | null => +{ + if(!node) return null; + if((node.pageId === id) && (node !== rootNode)) return node; + + for(const child of node.children) + { + const found = findNodeById(id, child, rootNode); + + if(found) return found; + } + + return null; +}; + +/** + * Depth-first search by pageName. Same exclusion of the root as + * `findNodeById`. + */ +export const findNodeByName = (name: string, node: ICatalogNode | null, rootNode: ICatalogNode | null): ICatalogNode | null => +{ + if(!node) return null; + if((node.pageName === name) && (node !== rootNode)) return node; + + for(const child of node.children) + { + const found = findNodeByName(name, child, rootNode); + + if(found) return found; + } + + return null; +}; + +/** + * Lookup the list of catalog nodes a given offer appears under. When + * `onlyVisible` is true the helper falls back to the full list if the + * filtered subset is empty — matches the original behavior in + * `getNodesByOfferId(offerId, true)`. + */ +export const getNodesByOfferIdFromMap = ( + offerId: number, + offersToNodes: Map | null | undefined, + onlyVisible: boolean = false +): ICatalogNode[] | null => +{ + if(!offersToNodes || !offersToNodes.size) return null; + + if(onlyVisible) + { + const offers = offersToNodes.get(offerId); + const visible: ICatalogNode[] = []; + + if(offers && offers.length) + { + for(const offer of offers) + { + if(offer.isVisible) visible.push(offer); + } + } + + if(visible.length) return visible; + } + + return offersToNodes.get(offerId) ?? null; +}; + +/** + * Turn the server-side NodeData tree into a CatalogNode tree paired + * with an offerId → nodes index map. Pure (besides the `new + * CatalogNode` construction). + * + * Original lived inline inside the `CatalogPagesListEvent` handler; + * extracted so the reducer is testable without rendering the hook. + */ +export const buildCatalogNodeTree = (root: NodeData): { rootNode: ICatalogNode; offersToNodes: Map } => +{ + const offersToNodes: Map = new Map(); + + const walk = (node: NodeData, depth: number, parent: ICatalogNode | null): ICatalogNode => + { + const catalogNode = (new CatalogNode(node, depth, parent) as ICatalogNode); + + for(const offerId of catalogNode.offerIds) + { + const existing = offersToNodes.get(offerId); + + if(existing) existing.push(catalogNode); + else offersToNodes.set(offerId, [ catalogNode ]); + } + + for(const child of node.children) catalogNode.addChild(walk(child, depth + 1, catalogNode)); + + return catalogNode; + }; + + return { rootNode: walk(root, 0, null), offersToNodes }; +}; + +/** + * Pure-input version of the placement-status decision. The original + * `getBuilderFurniPlaceableStatus` closes over a handful of state + * slices + reads `GetRoomSession()` / `GetRoomEngine()` / + * `GetSessionDataManager()` directly. Pulling those reads up to the + * call site (the hook still does them) makes the rest of the + * decision tree testable in isolation. + * + * `roomSession` may be null (user is in the hotel view, not a room). + * `usersInRoomMinusSelf` is the number of non-moderator, non-self + * users sharing the room — only consulted when `secondsLeft <= 0`, + * because the limit-reached / not-in-room paths short-circuit first. + */ +export interface BuilderPlacementStatusInput +{ + offer: IPurchasableOffer | null | undefined; + roomSession: { isGuildRoom: boolean; isRoomOwner: boolean; controllerLevel: number } | null; + secondsLeft: number; + furniCount: number; + furniLimit: number; + builderPlacementAllowedInCurrentRoom: boolean; + builderPlacementBlockedByVisitors: boolean; + /** Count of non-moderator, non-self users in the room. Only consulted when `secondsLeft <= 0`. */ + visitorCount?: number; +} + +export const resolveBuilderFurniPlaceableStatus = (input: BuilderPlacementStatusInput): BuilderFurniPlaceableStatus => +{ + const { offer, roomSession, secondsLeft, furniCount, furniLimit, builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, visitorCount = 0 } = input; + + if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER; + + if(!roomSession) return BuilderFurniPlaceableStatus.NOT_IN_ROOM; + + const canUseGuildAdminFallback = (roomSession.isGuildRoom + && (roomSession.controllerLevel >= RoomControllerLevel.GUILD_ADMIN) + && (secondsLeft > 0)); + + const usesSharedPlacementPool = (!roomSession.isRoomOwner && (builderPlacementAllowedInCurrentRoom || canUseGuildAdminFallback)); + + if(!roomSession.isRoomOwner && !builderPlacementAllowedInCurrentRoom && !canUseGuildAdminFallback) + { + return BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN; + } + + if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit))) + { + return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED; + } + + if((secondsLeft <= 0) && builderPlacementBlockedByVisitors) + { + return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM; + } + + if((secondsLeft <= 0) && (visitorCount > 0)) + { + return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM; + } + + return BuilderFurniPlaceableStatus.OKAY; +}; + +// Re-exports for the legacy categories so the call site in useCatalog +// doesn't need to know whether the constant comes from the renderer +// SDK enum or our own copy. +export { RoomControllerLevel, RoomObjectCategory, RoomObjectType }; diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 9676eba..0f21028 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -1,10 +1,11 @@ -import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, NodeData, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomControllerLevel, RoomEngineObjectPlacedEvent, RoomObjectCategory, RoomObjectPlacementSource, RoomObjectType, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer'; +import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useBetween } from 'use-between'; -import { BuilderFurniPlaceableStatus, CatalogNode, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api'; +import { BuilderFurniPlaceableStatus, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api'; import { CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent, InventoryFurniAddedEvent } from '../../events'; import { useMessageEvent, useNitroEvent, useUiEvent } from '../events'; import { useNotification } from '../notification'; +import { buildCatalogNodeTree, findNodeById, findNodeByName, getNodesByOfferIdFromMap, getOfferProductKeys, normalizeCatalogType, resolveBuilderFurniPlaceableStatus, RoomControllerLevel, RoomObjectCategory, RoomObjectType } from './useCatalog.helpers'; import { useCatalogPlaceMultipleItems } from './useCatalogPlaceMultipleItems'; import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConfirmation'; @@ -62,13 +63,6 @@ const useCatalogState = () => setIsVisible(false); }, []); - const normalizeCatalogType = useCallback((type?: string) => - { - if(type === CatalogType.BUILDER) return CatalogType.BUILDER; - - return CatalogType.NORMAL; - }, []); - const resetVisibleCatalogState = useCallback((type?: string) => { requestedPage.current.resetRequest(); @@ -85,7 +79,7 @@ const useCatalogState = () => setFrontPageItems([]); setNavigationHidden(false); setCurrentType(normalizeCatalogType(type)); - }, [ normalizeCatalogType ]); + }, []); const openCatalogByType = useCallback((type?: string) => { @@ -97,7 +91,7 @@ const useCatalogState = () => } setIsVisible(true); - }, [ currentType, normalizeCatalogType, resetVisibleCatalogState ]); + }, [ currentType, resetVisibleCatalogState ]); const toggleCatalogByType = useCallback((type?: string) => { @@ -116,54 +110,60 @@ const useCatalogState = () => } setIsVisible(true); - }, [ isVisible, currentType, normalizeCatalogType, resetVisibleCatalogState ]); + }, [ isVisible, currentType, resetVisibleCatalogState ]); const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) => { - if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER; - const roomSession = GetRoomSession(); - const canUseGuildAdminFallback = (!!roomSession - && roomSession.isGuildRoom - && (roomSession.controllerLevel >= RoomControllerLevel.GUILD_ADMIN) - && (secondsLeft > 0)); - const usesSharedPlacementPool = (!!roomSession && !roomSession.isRoomOwner && (builderPlacementAllowedInCurrentRoom || canUseGuildAdminFallback)); - if(!roomSession) return BuilderFurniPlaceableStatus.NOT_IN_ROOM; + // Count non-self, non-moderator users sharing the room. Only + // matters when the subscription has expired — the pure helper + // short-circuits on the limit-reached / not-in-room paths + // first, so we skip the room scan when there's still time on + // the clock. + let visitorCount = 0; - if(!roomSession.isRoomOwner && !builderPlacementAllowedInCurrentRoom && !canUseGuildAdminFallback) return BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN; - - if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit))) return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED; - - if((secondsLeft <= 0) && builderPlacementBlockedByVisitors) return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM; - - if(secondsLeft <= 0) + if(roomSession && (secondsLeft <= 0) && !builderPlacementBlockedByVisitors) { const roomEngine = GetRoomEngine(); const userDataManager = roomSession.userDataManager; const sessionDataManager = GetSessionDataManager(); - if(!roomEngine || !userDataManager || !sessionDataManager) return BuilderFurniPlaceableStatus.OKAY; - - const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT); - - if(!roomObjects || !roomObjects.length) return BuilderFurniPlaceableStatus.OKAY; - - for(const roomObject of roomObjects) + if(roomEngine && userDataManager && sessionDataManager) { - if(!roomObject) continue; + const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT); - const userData = userDataManager.getUserDataByIndex(roomObject.id); + if(roomObjects && roomObjects.length) + { + for(const roomObject of roomObjects) + { + if(!roomObject) continue; - if(!userData || (userData.type !== RoomObjectType.USER)) continue; - if(userData.webID === sessionDataManager.userId) continue; - if(userData.isModerator) continue; + const userData = userDataManager.getUserDataByIndex(roomObject.id); - return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM; + if(!userData || (userData.type !== RoomObjectType.USER)) continue; + if(userData.webID === sessionDataManager.userId) continue; + if(userData.isModerator) continue; + + visitorCount++; + break; + } + } } } - return BuilderFurniPlaceableStatus.OKAY; + return resolveBuilderFurniPlaceableStatus({ + offer, + roomSession: roomSession + ? { isGuildRoom: roomSession.isGuildRoom, isRoomOwner: roomSession.isRoomOwner, controllerLevel: roomSession.controllerLevel } + : null, + secondsLeft, + furniCount, + furniLimit, + builderPlacementAllowedInCurrentRoom, + builderPlacementBlockedByVisitors, + visitorCount + }); }, [ builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, furniCount, furniLimit, secondsLeft ]); const isDraggable = useCallback((offer: IPurchasableOffer) => @@ -294,70 +294,12 @@ const useCatalogState = () => }); }, [ resetObjectMover, resetRoomPaint ]); - const getNodeById = useCallback((id: number, node: ICatalogNode) => - { - if((node.pageId === id) && (node !== rootNode)) return node; + const getNodeById = useCallback((id: number, node: ICatalogNode) => findNodeById(id, node, rootNode), [ rootNode ]); - for(const child of node.children) - { - const found = (getNodeById(id, child)); - - if(found) return found; - } - - return null; - }, [ rootNode ]); - - const getNodeByName = useCallback((name: string, node: ICatalogNode) => - { - if((node.pageName === name) && (node !== rootNode)) return node; - - for(const child of node.children) - { - const found = (getNodeByName(name, child)); - - if(found) return found; - } - - return null; - }, [ rootNode ]); + const getNodeByName = useCallback((name: string, node: ICatalogNode) => findNodeByName(name, node, rootNode), [ rootNode ]); const getNodesByOfferId = useCallback((offerId: number, flag: boolean = false) => - { - if(!offersToNodes || !offersToNodes.size) return null; - - if(flag) - { - const nodes: ICatalogNode[] = []; - const offers = offersToNodes.get(offerId); - - if(offers && offers.length) for(const offer of offers) (offer.isVisible && nodes.push(offer)); - - if(nodes.length) return nodes; - } - - return offersToNodes.get(offerId); - }, [ offersToNodes ]); - - const getOfferProductKeys = useCallback((offer: IPurchasableOffer) => - { - const product = offer?.product; - const keys: string[] = []; - - if(!product) return keys; - - if(product.productType && (product.productClassId >= 0)) - { - keys.push(`${ product.productType }:id:${ product.productClassId }`); - } - - if(product.productType && product.furnitureData?.className?.length) - { - keys.push(`${ product.productType }:class:${ product.furnitureData.className }`); - } - - return keys; - }, []); + getNodesByOfferIdFromMap(offerId, offersToNodes, flag), [ offersToNodes ]); const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) => { @@ -365,7 +307,7 @@ const useCatalogState = () => { resolvedOffersByProductKey.current.set(key, offer); } - }, [ getOfferProductKeys ]); + }, []); const applySelectedOffer = useCallback((offer: IPurchasableOffer) => { @@ -563,27 +505,10 @@ const useCatalogState = () => if(parserCatalogType !== currentType) return; - const offers: Map = new Map(); + const { rootNode: builtRoot, offersToNodes: builtOffers } = buildCatalogNodeTree(parser.root); - const getCatalogNode = (node: NodeData, depth: number, parent: ICatalogNode) => - { - const catalogNode = (new CatalogNode(node, depth, parent) as ICatalogNode); - - for(const offerId of catalogNode.offerIds) - { - if(offers.has(offerId)) offers.get(offerId).push(catalogNode); - else offers.set(offerId, [ catalogNode ]); - } - - depth++; - - for(const child of node.children) catalogNode.addChild(getCatalogNode(child, depth, catalogNode)); - - return catalogNode; - }; - - setRootNode(getCatalogNode(parser.root, 0, null)); - setOffersToNodes(offers); + setRootNode(builtRoot); + setOffersToNodes(builtOffers); }); useMessageEvent(CatalogPageMessageEvent, event => diff --git a/tests/mocks/renderer-mock.ts b/tests/mocks/renderer-mock.ts index acd410e..25137f6 100644 --- a/tests/mocks/renderer-mock.ts +++ b/tests/mocks/renderer-mock.ts @@ -98,11 +98,9 @@ const makeEnumProxy = (label: string) => new Proxy({}, { 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'); @@ -113,6 +111,29 @@ export const AvatarSetType = makeEnumProxy('AvatarSetType'); export const AvatarAction = makeEnumProxy('AvatarAction'); export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter'); +// Numeric enums — values mirror the real renderer SDK so comparisons +// (`controllerLevel >= GUILD_ADMIN`, category branching) keep working. + +export class RoomControllerLevel +{ + static readonly NONE = 0; + static readonly GUEST = 1; + static readonly GUILD_MEMBER = 2; + static readonly GUILD_ADMIN = 3; + static readonly ROOM_OWNER = 4; + static readonly MODERATOR = 5; +} + +export class RoomObjectCategory +{ + static readonly MINIMUM = 0; + static readonly ROOM = 10; + static readonly UNIT = 20; + static readonly FLOOR = 30; + static readonly WALL = 40; + static readonly MAXIMUM = 50; +} + // --------------------------------------------------------------------------- // Doorbell event class // --------------------------------------------------------------------------- diff --git a/tests/useCatalog.helpers.test.ts b/tests/useCatalog.helpers.test.ts new file mode 100644 index 0000000..5a64fba --- /dev/null +++ b/tests/useCatalog.helpers.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from 'vitest'; +import { BuilderFurniPlaceableStatus } from '../src/api/catalog/BuilderFurniPlaceableStatus'; +import { CatalogType } from '../src/api/catalog/CatalogType'; +import { + buildCatalogNodeTree, + findNodeById, + findNodeByName, + getNodesByOfferIdFromMap, + getOfferProductKeys, + normalizeCatalogType, + resolveBuilderFurniPlaceableStatus +} from '../src/hooks/catalog/useCatalog.helpers'; + +// --------------------------------------------------------------------------- +// normalizeCatalogType +// --------------------------------------------------------------------------- + +describe('normalizeCatalogType', () => +{ + it('returns BUILDER when explicitly asked for BUILDER', () => + { + expect(normalizeCatalogType(CatalogType.BUILDER)).toBe(CatalogType.BUILDER); + }); + + it('returns NORMAL for the explicit NORMAL value', () => + { + expect(normalizeCatalogType(CatalogType.NORMAL)).toBe(CatalogType.NORMAL); + }); + + it('returns NORMAL when type is omitted', () => + { + expect(normalizeCatalogType()).toBe(CatalogType.NORMAL); + }); + + it('returns NORMAL for any unknown string', () => + { + expect(normalizeCatalogType('something_else')).toBe(CatalogType.NORMAL); + expect(normalizeCatalogType('')).toBe(CatalogType.NORMAL); + }); +}); + +// --------------------------------------------------------------------------- +// getOfferProductKeys +// --------------------------------------------------------------------------- + +describe('getOfferProductKeys', () => +{ + const makeOffer = (overrides: any = {}) => + ({ + product: { + productType: 'floor', + productClassId: 42, + furnitureData: { className: 'chair_basic' }, + ...overrides + } + }) as any; + + it('returns both id and className keys when the product has both', () => + { + expect(getOfferProductKeys(makeOffer())).toEqual([ + 'floor:id:42', + 'floor:class:chair_basic' + ]); + }); + + it('omits the id key when productClassId is negative', () => + { + const offer = makeOffer({ productClassId: -1 }); + + expect(getOfferProductKeys(offer)).toEqual([ 'floor:class:chair_basic' ]); + }); + + it('omits the className key when furnitureData has no className', () => + { + const offer = makeOffer({ furnitureData: { className: '' } }); + + expect(getOfferProductKeys(offer)).toEqual([ 'floor:id:42' ]); + }); + + it('returns an empty array when the offer has no product', () => + { + expect(getOfferProductKeys(null)).toEqual([]); + expect(getOfferProductKeys(undefined)).toEqual([]); + expect(getOfferProductKeys({} as any)).toEqual([]); + }); + + it('returns an empty array when productType is missing', () => + { + const offer = makeOffer({ productType: '' }); + + expect(getOfferProductKeys(offer)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// findNodeById / findNodeByName +// --------------------------------------------------------------------------- + +const makeNode = (overrides: { pageId?: number; pageName?: string; children?: any[] } = {}) => + ({ + pageId: overrides.pageId ?? -1, + pageName: overrides.pageName ?? 'unnamed', + isVisible: true, + children: overrides.children ?? [] + }); + +describe('findNodeById', () => +{ + it('returns null when the input node is null', () => + { + expect(findNodeById(7, null, null)).toBeNull(); + }); + + it('skips the root node even when its pageId matches', () => + { + const root = makeNode({ pageId: 7, pageName: 'root' }) as any; + + expect(findNodeById(7, root, root)).toBeNull(); + }); + + it('finds an immediate child by pageId', () => + { + const child = makeNode({ pageId: 7, pageName: 'shop' }) as any; + const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any; + + expect(findNodeById(7, root, root)).toBe(child); + }); + + it('descends into grandchildren', () => + { + const grandchild = makeNode({ pageId: 42, pageName: 'sale' }) as any; + const child = makeNode({ pageId: 7, pageName: 'shop', children: [ grandchild ] }) as any; + const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any; + + expect(findNodeById(42, root, root)).toBe(grandchild); + }); + + it('returns null when no node has that pageId', () => + { + const child = makeNode({ pageId: 7, pageName: 'shop' }) as any; + const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any; + + expect(findNodeById(99, root, root)).toBeNull(); + }); +}); + +describe('findNodeByName', () => +{ + it('finds a node by pageName ignoring the root', () => + { + const child = makeNode({ pageName: 'frontpage' }) as any; + const root = makeNode({ pageName: 'root', children: [ child ] }) as any; + + expect(findNodeByName('frontpage', root, root)).toBe(child); + expect(findNodeByName('root', root, root)).toBeNull(); + }); + + it('returns null when nothing matches', () => + { + const root = makeNode({ pageName: 'root', children: [ makeNode({ pageName: 'a' }) as any ] }) as any; + + expect(findNodeByName('b', root, root)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// getNodesByOfferIdFromMap +// --------------------------------------------------------------------------- + +describe('getNodesByOfferIdFromMap', () => +{ + const visibleNode = (id: number) => ({ pageId: id, isVisible: true } as any); + const hiddenNode = (id: number) => ({ pageId: id, isVisible: false } as any); + + it('returns null when the map is missing or empty', () => + { + expect(getNodesByOfferIdFromMap(1, null)).toBeNull(); + expect(getNodesByOfferIdFromMap(1, undefined)).toBeNull(); + expect(getNodesByOfferIdFromMap(1, new Map())).toBeNull(); + }); + + it('returns the raw bucket when onlyVisible is false', () => + { + const bucket = [ visibleNode(1), hiddenNode(2) ]; + const map = new Map([ [ 9, bucket ] ]); + + expect(getNodesByOfferIdFromMap(9, map)).toBe(bucket); + }); + + it('filters out hidden nodes when onlyVisible is true', () => + { + const visible = visibleNode(1); + const map = new Map([ [ 9, [ visible, hiddenNode(2) ] ] ]); + + expect(getNodesByOfferIdFromMap(9, map, true)).toEqual([ visible ]); + }); + + it('falls back to the raw bucket when no visible nodes remain', () => + { + const bucket = [ hiddenNode(1), hiddenNode(2) ]; + const map = new Map([ [ 9, bucket ] ]); + + expect(getNodesByOfferIdFromMap(9, map, true)).toBe(bucket); + }); + + it('returns null for an offerId not in the map', () => + { + const map = new Map([ [ 1, [ visibleNode(1) ] ] ]); + + expect(getNodesByOfferIdFromMap(99, map)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// buildCatalogNodeTree +// --------------------------------------------------------------------------- + +describe('buildCatalogNodeTree', () => +{ + // CatalogNode in the real codebase reads `node.pageId`, `node.pageName`, + // `node.offerIds`, `node.children`. Anything else is irrelevant to the + // build step. + const makeNodeData = (overrides: any = {}) => + ({ + pageId: -1, + pageName: 'unnamed', + localization: '', + iconId: -1, + offerIds: [] as number[], + children: [] as any[], + visible: true, + ...overrides + }); + + it('returns a CatalogNode root with the depth=0', () => + { + const rootData = makeNodeData({ pageId: 0, pageName: 'root' }); + const { rootNode, offersToNodes } = buildCatalogNodeTree(rootData as any); + + expect(rootNode.pageId).toBe(0); + expect(rootNode.depth).toBe(0); + expect(offersToNodes.size).toBe(0); + }); + + it('walks children depth-first and tracks offerId mappings', () => + { + const leaf = makeNodeData({ pageId: 5, pageName: 'sale', offerIds: [ 100, 200 ] }); + const branch = makeNodeData({ pageId: 3, pageName: 'shop', offerIds: [ 100 ], children: [ leaf ] }); + const rootData = makeNodeData({ pageId: 0, pageName: 'root', children: [ branch ] }); + + const { rootNode, offersToNodes } = buildCatalogNodeTree(rootData as any); + + // tree shape + expect(rootNode.children).toHaveLength(1); + expect(rootNode.children[0].pageId).toBe(3); + expect(rootNode.children[0].children[0].pageId).toBe(5); + + // depth incremented + expect(rootNode.depth).toBe(0); + expect(rootNode.children[0].depth).toBe(1); + expect(rootNode.children[0].children[0].depth).toBe(2); + + // offerId index records both nodes for offer 100, only the leaf for 200 + expect(offersToNodes.get(100)).toHaveLength(2); + expect(offersToNodes.get(100)?.map(n => n.pageId)).toEqual([ 3, 5 ]); + expect(offersToNodes.get(200)?.map(n => n.pageId)).toEqual([ 5 ]); + }); + + it('preserves child-parent relationships', () => + { + const leaf = makeNodeData({ pageId: 5, pageName: 'sale' }); + const rootData = makeNodeData({ pageId: 0, pageName: 'root', children: [ leaf ] }); + + const { rootNode } = buildCatalogNodeTree(rootData as any); + + expect(rootNode.children[0].parent).toBe(rootNode); + }); +}); + +// --------------------------------------------------------------------------- +// resolveBuilderFurniPlaceableStatus +// --------------------------------------------------------------------------- + +describe('resolveBuilderFurniPlaceableStatus', () => +{ + const offer = { offerId: 1 } as any; + + const baseInput = { + offer, + roomSession: { isGuildRoom: false, isRoomOwner: true, controllerLevel: 0 }, + secondsLeft: 60, + furniCount: 0, + furniLimit: 10, + builderPlacementAllowedInCurrentRoom: false, + builderPlacementBlockedByVisitors: false, + visitorCount: 0 + }; + + it('returns MISSING_OFFER when offer is null', () => + { + expect(resolveBuilderFurniPlaceableStatus({ ...baseInput, offer: null })).toBe(BuilderFurniPlaceableStatus.MISSING_OFFER); + }); + + it('returns NOT_IN_ROOM when roomSession is null', () => + { + expect(resolveBuilderFurniPlaceableStatus({ ...baseInput, roomSession: null })).toBe(BuilderFurniPlaceableStatus.NOT_IN_ROOM); + }); + + it('returns OKAY for the room owner with time on the clock', () => + { + expect(resolveBuilderFurniPlaceableStatus(baseInput)).toBe(BuilderFurniPlaceableStatus.OKAY); + }); + + it('returns NOT_GROUP_ADMIN for a non-owner without group fallback or shared pool', () => + { + const input = { + ...baseInput, + roomSession: { isGuildRoom: false, isRoomOwner: false, controllerLevel: 0 } + }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN); + }); + + it('returns OKAY for guild admin with subscription time remaining', () => + { + const input = { + ...baseInput, + roomSession: { isGuildRoom: true, isRoomOwner: false, controllerLevel: 4 /* GUILD_ADMIN */ }, + secondsLeft: 60 + }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY); + }); + + it('returns FURNI_LIMIT_REACHED when count meets the limit and no shared pool applies', () => + { + const input = { ...baseInput, furniCount: 10, furniLimit: 10 }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED); + }); + + it('skips the furni limit when builderPlacementAllowedInCurrentRoom for a non-owner', () => + { + const input = { + ...baseInput, + roomSession: { isGuildRoom: false, isRoomOwner: false, controllerLevel: 0 }, + furniCount: 99, + furniLimit: 10, + builderPlacementAllowedInCurrentRoom: true + }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY); + }); + + it('returns VISITORS_IN_ROOM when the subscription has expired and the flag is set', () => + { + const input = { + ...baseInput, + secondsLeft: 0, + builderPlacementBlockedByVisitors: true + }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.VISITORS_IN_ROOM); + }); + + it('returns VISITORS_IN_ROOM when the subscription has expired and there are visitors counted', () => + { + const input = { ...baseInput, secondsLeft: 0, visitorCount: 3 }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.VISITORS_IN_ROOM); + }); + + it('returns OKAY when the subscription has expired but the room is empty', () => + { + const input = { ...baseInput, secondsLeft: 0, visitorCount: 0 }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY); + }); +});