mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user