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
+6 -2
View File
@@ -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 (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` | | God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends` |
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella | | `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) | | 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 | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating |
| Not yet | Notes | | 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 `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. | | 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.) | | 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` - Asset middleware: `nitroAssetsServer()` in `vite.config.mjs`
- Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()` - Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()`
before `import('./index')`) 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` - Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts`
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
+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 | | 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 | | 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 ### Tests
- Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom` - Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom`
configured. Separate `vitest.config.mts` so the runner doesn't drag in configured. Separate `vitest.config.mts` so the runner doesn't drag in
the renderer SDK aliases from `vite.config.mjs`. 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 - `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
factory. factory.
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants - `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
@@ -504,6 +532,13 @@ Status after this round of work:
bail-out branches (state-not-AvatarInfoUser, mismatched bail-out branches (state-not-AvatarInfoUser, mismatched
user/roomIndex, equal-after-dedup) + the figure / favorite-group user/roomIndex, equal-after-dedup) + the figure / favorite-group
apply paths. 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): Component-/hook-level suites (on the new renderer-SDK mock):
- `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render - `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render
+222
View File
@@ -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<number, ICatalogNode[]> | 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<number, ICatalogNode[]> } =>
{
const offersToNodes: Map<number, ICatalogNode[]> = 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 };
+48 -123
View File
@@ -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 { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between'; 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 { CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent, InventoryFurniAddedEvent } from '../../events';
import { useMessageEvent, useNitroEvent, useUiEvent } from '../events'; import { useMessageEvent, useNitroEvent, useUiEvent } from '../events';
import { useNotification } from '../notification'; import { useNotification } from '../notification';
import { buildCatalogNodeTree, findNodeById, findNodeByName, getNodesByOfferIdFromMap, getOfferProductKeys, normalizeCatalogType, resolveBuilderFurniPlaceableStatus, RoomControllerLevel, RoomObjectCategory, RoomObjectType } from './useCatalog.helpers';
import { useCatalogPlaceMultipleItems } from './useCatalogPlaceMultipleItems'; import { useCatalogPlaceMultipleItems } from './useCatalogPlaceMultipleItems';
import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConfirmation'; import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConfirmation';
@@ -62,13 +63,6 @@ const useCatalogState = () =>
setIsVisible(false); setIsVisible(false);
}, []); }, []);
const normalizeCatalogType = useCallback((type?: string) =>
{
if(type === CatalogType.BUILDER) return CatalogType.BUILDER;
return CatalogType.NORMAL;
}, []);
const resetVisibleCatalogState = useCallback((type?: string) => const resetVisibleCatalogState = useCallback((type?: string) =>
{ {
requestedPage.current.resetRequest(); requestedPage.current.resetRequest();
@@ -85,7 +79,7 @@ const useCatalogState = () =>
setFrontPageItems([]); setFrontPageItems([]);
setNavigationHidden(false); setNavigationHidden(false);
setCurrentType(normalizeCatalogType(type)); setCurrentType(normalizeCatalogType(type));
}, [ normalizeCatalogType ]); }, []);
const openCatalogByType = useCallback((type?: string) => const openCatalogByType = useCallback((type?: string) =>
{ {
@@ -97,7 +91,7 @@ const useCatalogState = () =>
} }
setIsVisible(true); setIsVisible(true);
}, [ currentType, normalizeCatalogType, resetVisibleCatalogState ]); }, [ currentType, resetVisibleCatalogState ]);
const toggleCatalogByType = useCallback((type?: string) => const toggleCatalogByType = useCallback((type?: string) =>
{ {
@@ -116,54 +110,60 @@ const useCatalogState = () =>
} }
setIsVisible(true); setIsVisible(true);
}, [ isVisible, currentType, normalizeCatalogType, resetVisibleCatalogState ]); }, [ isVisible, currentType, resetVisibleCatalogState ]);
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) => const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
{ {
if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER;
const roomSession = GetRoomSession(); 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(roomSession && (secondsLeft <= 0) && !builderPlacementBlockedByVisitors)
if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit))) return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED;
if((secondsLeft <= 0) && builderPlacementBlockedByVisitors) return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
if(secondsLeft <= 0)
{ {
const roomEngine = GetRoomEngine(); const roomEngine = GetRoomEngine();
const userDataManager = roomSession.userDataManager; const userDataManager = roomSession.userDataManager;
const sessionDataManager = GetSessionDataManager(); const sessionDataManager = GetSessionDataManager();
if(!roomEngine || !userDataManager || !sessionDataManager) return BuilderFurniPlaceableStatus.OKAY; if(roomEngine && userDataManager && sessionDataManager)
const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT);
if(!roomObjects || !roomObjects.length) return BuilderFurniPlaceableStatus.OKAY;
for(const roomObject of roomObjects)
{ {
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; const userData = userDataManager.getUserDataByIndex(roomObject.id);
if(userData.webID === sessionDataManager.userId) continue;
if(userData.isModerator) continue;
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 ]); }, [ builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, furniCount, furniLimit, secondsLeft ]);
const isDraggable = useCallback((offer: IPurchasableOffer) => const isDraggable = useCallback((offer: IPurchasableOffer) =>
@@ -294,70 +294,12 @@ const useCatalogState = () =>
}); });
}, [ resetObjectMover, resetRoomPaint ]); }, [ resetObjectMover, resetRoomPaint ]);
const getNodeById = useCallback((id: number, node: ICatalogNode) => const getNodeById = useCallback((id: number, node: ICatalogNode) => findNodeById(id, node, rootNode), [ rootNode ]);
{
if((node.pageId === id) && (node !== rootNode)) return node;
for(const child of node.children) const getNodeByName = useCallback((name: string, node: ICatalogNode) => findNodeByName(name, node, rootNode), [ rootNode ]);
{
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 getNodesByOfferId = useCallback((offerId: number, flag: boolean = false) => const getNodesByOfferId = useCallback((offerId: number, flag: boolean = false) =>
{ getNodesByOfferIdFromMap(offerId, offersToNodes, flag), [ offersToNodes ]);
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;
}, []);
const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) => const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) =>
{ {
@@ -365,7 +307,7 @@ const useCatalogState = () =>
{ {
resolvedOffersByProductKey.current.set(key, offer); resolvedOffersByProductKey.current.set(key, offer);
} }
}, [ getOfferProductKeys ]); }, []);
const applySelectedOffer = useCallback((offer: IPurchasableOffer) => const applySelectedOffer = useCallback((offer: IPurchasableOffer) =>
{ {
@@ -563,27 +505,10 @@ const useCatalogState = () =>
if(parserCatalogType !== currentType) return; if(parserCatalogType !== currentType) return;
const offers: Map<number, ICatalogNode[]> = new Map(); const { rootNode: builtRoot, offersToNodes: builtOffers } = buildCatalogNodeTree(parser.root);
const getCatalogNode = (node: NodeData, depth: number, parent: ICatalogNode) => setRootNode(builtRoot);
{ setOffersToNodes(builtOffers);
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);
}); });
useMessageEvent<CatalogPageMessageEvent>(CatalogPageMessageEvent, event => useMessageEvent<CatalogPageMessageEvent>(CatalogPageMessageEvent, event =>
+23 -2
View File
@@ -98,11 +98,9 @@ const makeEnumProxy = (label: string) => new Proxy({}, {
export const NitroEventType = makeEnumProxy('NitroEventType'); export const NitroEventType = makeEnumProxy('NitroEventType');
export const MouseEventType = makeEnumProxy('MouseEventType'); export const MouseEventType = makeEnumProxy('MouseEventType');
export const TouchEventType = makeEnumProxy('TouchEventType'); export const TouchEventType = makeEnumProxy('TouchEventType');
export const RoomObjectCategory = makeEnumProxy('RoomObjectCategory');
export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource'); export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource');
export const RoomObjectType = makeEnumProxy('RoomObjectType'); export const RoomObjectType = makeEnumProxy('RoomObjectType');
export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable'); export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable');
export const RoomControllerLevel = makeEnumProxy('RoomControllerLevel');
export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum'); export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum');
export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum'); export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum');
export const FurnitureType = makeEnumProxy('FurnitureType'); export const FurnitureType = makeEnumProxy('FurnitureType');
@@ -113,6 +111,29 @@ export const AvatarSetType = makeEnumProxy('AvatarSetType');
export const AvatarAction = makeEnumProxy('AvatarAction'); export const AvatarAction = makeEnumProxy('AvatarAction');
export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter'); 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 // Doorbell event class
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+379
View File
@@ -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);
});
});