mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
tests: co-locate every Vitest suite next to its subject under src/
Eliminate the parallel `tests/` tree. Each `*.test.ts` / `*.test.tsx` now sits in the same directory as the module it covers, mirroring its filename (`Foo.ts` ↔ `Foo.test.ts`). The renderer-SDK mock used by component / hook tests moves to `src/__mocks__/nitro-renderer.ts` and the Vitest setup file becomes `src/test-setup.ts` — both still wired through `vitest.config.mts` exactly as before, only the paths changed. All 13 suites + 178/178 cases still pass. The production build is unaffected: rollup only follows imports from `src/index.tsx` and never crosses into `.test.ts` files, so test code is naturally tree-shaken out of the bundle. `yarn build` output is byte-for-byte the same on the user-facing chunks. tsconfig drops the now-redundant `tests` include entry. CLAUDE.md 'Layout convention' replaces the old `tests/` row with three rows documenting the new co-located convention, the `__mocks__/` directory and the `test-setup.ts` entry; ARCHITECTURE.md picks up the same update. The 'DO NOT CHANGE' qualifier on the layout is preserved — this rewrite IS the change, decided deliberately to make tests a first-class part of the source tree rather than a sibling project.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// `useCatalogStore` mounts ~30 useState calls, opens a fresh
|
||||
// RoomPreviewer, subscribes to a dozen renderer message events, and
|
||||
// reaches into `useNotification()` for the alert helpers — too much
|
||||
// surface to render under jsdom and not what these tests are about.
|
||||
//
|
||||
// We just want to lock down the *contract* of the three filters
|
||||
// (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) and
|
||||
// the shim: each one must read its specific subset of keys from the
|
||||
// same `useBetween` singleton.
|
||||
//
|
||||
// Stub `use-between` so all four hooks share one deterministic store
|
||||
// object. `vi.hoisted` lets us reference the fake from the mock
|
||||
// factory (which is itself hoisted).
|
||||
|
||||
const { fakeStore } = vi.hoisted(() =>
|
||||
{
|
||||
const fakeStore = {
|
||||
// Data slice
|
||||
isBusy: false,
|
||||
rootNode: null,
|
||||
offersToNodes: null,
|
||||
currentPage: null,
|
||||
currentOffer: null,
|
||||
frontPageItems: [],
|
||||
searchResult: null,
|
||||
roomPreviewer: null,
|
||||
catalogLocalizationVersion: 0,
|
||||
furniCount: 0,
|
||||
furniLimit: 0,
|
||||
maxFurniLimit: 0,
|
||||
secondsLeft: 0,
|
||||
secondsLeftWithGrace: 0,
|
||||
updateTime: 0,
|
||||
// UiState slice
|
||||
isVisible: false,
|
||||
setIsVisible: vi.fn(),
|
||||
pageId: -1,
|
||||
previousPageId: -1,
|
||||
currentType: 'NORMAL',
|
||||
activeNodes: [] as any[],
|
||||
navigationHidden: false,
|
||||
setNavigationHidden: vi.fn(),
|
||||
purchaseOptions: { quantity: 1 },
|
||||
setPurchaseOptions: vi.fn(),
|
||||
catalogPlaceMultipleObjects: false,
|
||||
setCatalogPlaceMultipleObjects: vi.fn(),
|
||||
setCurrentPage: vi.fn(),
|
||||
setCurrentOffer: vi.fn(),
|
||||
setSearchResult: vi.fn(),
|
||||
// Actions slice
|
||||
openCatalogByType: vi.fn(),
|
||||
toggleCatalogByType: vi.fn(),
|
||||
activateNode: vi.fn(),
|
||||
openPageById: vi.fn(),
|
||||
openPageByName: vi.fn(),
|
||||
openPageByOfferId: vi.fn(),
|
||||
requestOfferToMover: vi.fn(),
|
||||
selectCatalogOffer: vi.fn(),
|
||||
getNodeById: vi.fn(),
|
||||
getNodeByName: vi.fn(),
|
||||
getBuilderFurniPlaceableStatus: vi.fn()
|
||||
};
|
||||
|
||||
return { fakeStore };
|
||||
});
|
||||
|
||||
vi.mock('use-between', () => ({
|
||||
useBetween: () => fakeStore
|
||||
}));
|
||||
|
||||
// Import AFTER the mock is set up. The hooks resolve `useBetween` at
|
||||
// import time via the module graph, so the order matters.
|
||||
import { useCatalogActions, useCatalogData, useCatalogUiState } from './useCatalog';
|
||||
|
||||
describe('useCatalog filter contract', () =>
|
||||
{
|
||||
it('useCatalogData returns the read-only data slice', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useCatalogData());
|
||||
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'catalogLocalizationVersion',
|
||||
'currentOffer',
|
||||
'currentPage',
|
||||
'frontPageItems',
|
||||
'furniCount',
|
||||
'furniLimit',
|
||||
'isBusy',
|
||||
'maxFurniLimit',
|
||||
'offersToNodes',
|
||||
'roomPreviewer',
|
||||
'rootNode',
|
||||
'searchResult',
|
||||
'secondsLeft',
|
||||
'secondsLeftWithGrace',
|
||||
'updateTime'
|
||||
]);
|
||||
|
||||
// Reads point at the same underlying values.
|
||||
expect(result.current.rootNode).toBe(fakeStore.rootNode);
|
||||
expect(result.current.furniCount).toBe(fakeStore.furniCount);
|
||||
expect(result.current.frontPageItems).toBe(fakeStore.frontPageItems);
|
||||
});
|
||||
|
||||
it('useCatalogUiState returns the UI fields plus their setters', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useCatalogUiState());
|
||||
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'activeNodes',
|
||||
'catalogPlaceMultipleObjects',
|
||||
'currentType',
|
||||
'isVisible',
|
||||
'navigationHidden',
|
||||
'pageId',
|
||||
'previousPageId',
|
||||
'purchaseOptions',
|
||||
'setCatalogPlaceMultipleObjects',
|
||||
'setCurrentOffer',
|
||||
'setCurrentPage',
|
||||
'setIsVisible',
|
||||
'setNavigationHidden',
|
||||
'setPurchaseOptions',
|
||||
'setSearchResult'
|
||||
]);
|
||||
|
||||
expect(result.current.setIsVisible).toBe(fakeStore.setIsVisible);
|
||||
expect(result.current.setCurrentPage).toBe(fakeStore.setCurrentPage);
|
||||
});
|
||||
|
||||
it('useCatalogActions returns only imperative operations', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useCatalogActions());
|
||||
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'activateNode',
|
||||
'getBuilderFurniPlaceableStatus',
|
||||
'getNodeById',
|
||||
'getNodeByName',
|
||||
'openCatalogByType',
|
||||
'openPageById',
|
||||
'openPageByName',
|
||||
'openPageByOfferId',
|
||||
'requestOfferToMover',
|
||||
'selectCatalogOffer',
|
||||
'toggleCatalogByType'
|
||||
]);
|
||||
|
||||
// No data fields leak through.
|
||||
expect(result.current).not.toHaveProperty('rootNode');
|
||||
expect(result.current).not.toHaveProperty('isVisible');
|
||||
expect(result.current).not.toHaveProperty('currentPage');
|
||||
|
||||
expect(result.current.activateNode).toBe(fakeStore.activateNode);
|
||||
expect(result.current.openCatalogByType).toBe(fakeStore.openCatalogByType);
|
||||
});
|
||||
|
||||
it('all three filters observe the same singleton — refs are ===', () =>
|
||||
{
|
||||
const { result } = renderHook(() =>
|
||||
({
|
||||
data: useCatalogData(),
|
||||
ui: useCatalogUiState(),
|
||||
actions: useCatalogActions()
|
||||
}));
|
||||
|
||||
// Each slice reaches the same fakeStore via useBetween. Any
|
||||
// accidental copy would break these `===` checks.
|
||||
expect(result.current.actions.activateNode).toBe(fakeStore.activateNode);
|
||||
expect(result.current.actions.openCatalogByType).toBe(fakeStore.openCatalogByType);
|
||||
expect(result.current.ui.setIsVisible).toBe(fakeStore.setIsVisible);
|
||||
expect(result.current.ui.setCurrentPage).toBe(fakeStore.setCurrentPage);
|
||||
expect(result.current.data.rootNode).toBe(fakeStore.rootNode);
|
||||
expect(result.current.data.furniCount).toBe(fakeStore.furniCount);
|
||||
expect(result.current.data.roomPreviewer).toBe(fakeStore.roomPreviewer);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BuilderFurniPlaceableStatus } from '../../api/catalog/BuilderFurniPlaceableStatus';
|
||||
import { CatalogType } from '../../api/catalog/CatalogType';
|
||||
import {
|
||||
buildCatalogNodeTree,
|
||||
findNodeById,
|
||||
findNodeByName,
|
||||
getNodesByOfferIdFromMap,
|
||||
getOfferProductKeys,
|
||||
normalizeCatalogType,
|
||||
resolveBuilderFurniPlaceableStatus
|
||||
} from './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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CatalogType } from '../../api/catalog/CatalogType';
|
||||
import { getOffersStorageKey, getPagesStorageKey, normalizeCatalogType, parseOffers, parsePages, STORAGE_KEY_OFFERS_BUILDER, STORAGE_KEY_OFFERS_NORMAL, STORAGE_KEY_PAGES_BUILDER, STORAGE_KEY_PAGES_NORMAL } from './useCatalogFavorites.helpers';
|
||||
|
||||
describe('normalizeCatalogType', () =>
|
||||
{
|
||||
it('returns NORMAL when nothing is passed', () =>
|
||||
{
|
||||
expect(normalizeCatalogType()).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
|
||||
it('returns NORMAL for unknown strings', () =>
|
||||
{
|
||||
expect(normalizeCatalogType('not-a-real-type')).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
|
||||
it('returns BUILDER only for the exact BUILDER constant', () =>
|
||||
{
|
||||
expect(normalizeCatalogType(CatalogType.BUILDER)).toBe(CatalogType.BUILDER);
|
||||
});
|
||||
|
||||
it('returns NORMAL for NORMAL explicitly', () =>
|
||||
{
|
||||
expect(normalizeCatalogType(CatalogType.NORMAL)).toBe(CatalogType.NORMAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOffersStorageKey / getPagesStorageKey', () =>
|
||||
{
|
||||
it('routes the BUILDER catalog to the builder storage keys', () =>
|
||||
{
|
||||
expect(getOffersStorageKey(CatalogType.BUILDER)).toBe(STORAGE_KEY_OFFERS_BUILDER);
|
||||
expect(getPagesStorageKey(CatalogType.BUILDER)).toBe(STORAGE_KEY_PAGES_BUILDER);
|
||||
});
|
||||
|
||||
it('routes the NORMAL catalog to the normal storage keys', () =>
|
||||
{
|
||||
expect(getOffersStorageKey(CatalogType.NORMAL)).toBe(STORAGE_KEY_OFFERS_NORMAL);
|
||||
expect(getPagesStorageKey(CatalogType.NORMAL)).toBe(STORAGE_KEY_PAGES_NORMAL);
|
||||
});
|
||||
|
||||
it('falls back to NORMAL keys for unknown / missing catalog type', () =>
|
||||
{
|
||||
expect(getOffersStorageKey()).toBe(STORAGE_KEY_OFFERS_NORMAL);
|
||||
expect(getOffersStorageKey('garbage')).toBe(STORAGE_KEY_OFFERS_NORMAL);
|
||||
expect(getPagesStorageKey()).toBe(STORAGE_KEY_PAGES_NORMAL);
|
||||
expect(getPagesStorageKey('garbage')).toBe(STORAGE_KEY_PAGES_NORMAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOffers', () =>
|
||||
{
|
||||
it('returns an empty array on invalid JSON', () =>
|
||||
{
|
||||
expect(parseOffers('not json')).toEqual([]);
|
||||
expect(parseOffers('{')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the parsed value is not an array', () =>
|
||||
{
|
||||
expect(parseOffers('null')).toEqual([]);
|
||||
expect(parseOffers('{}')).toEqual([]);
|
||||
expect(parseOffers('42')).toEqual([]);
|
||||
expect(parseOffers('"hello"')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array unchanged', () =>
|
||||
{
|
||||
expect(parseOffers('[]')).toEqual([]);
|
||||
});
|
||||
|
||||
it('migrates the v2 number[] format into IFavoriteOffer[] with offerId only', () =>
|
||||
{
|
||||
expect(parseOffers('[101, 202, 303]')).toEqual([
|
||||
{ offerId: 101 },
|
||||
{ offerId: 202 },
|
||||
{ offerId: 303 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes through a well-formed v3 IFavoriteOffer[] unchanged', () =>
|
||||
{
|
||||
const v3 = [
|
||||
{ offerId: 5, name: 'red sofa', iconUrl: 'http://example.com/sofa.png' },
|
||||
{ offerId: 9 }
|
||||
];
|
||||
|
||||
expect(parseOffers(JSON.stringify(v3))).toEqual(v3);
|
||||
});
|
||||
|
||||
it('only triggers migration when the first element is a number (mixed arrays go through as-is)', () =>
|
||||
{
|
||||
const mixed = [ { offerId: 1 }, { offerId: 2 } ];
|
||||
|
||||
expect(parseOffers(JSON.stringify(mixed))).toEqual(mixed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePages', () =>
|
||||
{
|
||||
it('returns an empty array on invalid JSON', () =>
|
||||
{
|
||||
expect(parsePages('not json')).toEqual([]);
|
||||
expect(parsePages('}{')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the parsed value is not an array', () =>
|
||||
{
|
||||
expect(parsePages('null')).toEqual([]);
|
||||
expect(parsePages('{ "pages": [1, 2] }')).toEqual([]);
|
||||
expect(parsePages('"42"')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the parsed array as-is', () =>
|
||||
{
|
||||
expect(parsePages('[]')).toEqual([]);
|
||||
expect(parsePages('[1, 2, 3]')).toEqual([ 1, 2, 3 ]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user