mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
catalog: three-way singleton-filter split + first 3 consumer migrations
Completes the useCatalog decomposition. After the previous commit
extracted the pure helpers, this one splits the singleton-via-useBetween
store into three slice-specific entry points and migrates a handful of
consumers as proof.
`src/hooks/catalog/useCatalog.ts`
- Internal `useCatalogState` → renamed to `useCatalogStore` and is no
longer exported. The full return shape is unchanged so callers that
still go through the shim see the exact same object.
- Three new exports built on top of the same `useBetween` instance:
- `useCatalogData()` — server-driven read-only slice (rootNode,
offersToNodes, currentPage, currentOffer, frontPageItems,
searchResult, roomPreviewer, isBusy, catalog localization
version, Builders Club counters + timers).
- `useCatalogUiState()` — UI ephemeral state + writers
(isVisible, pageId, previousPageId, currentType, activeNodes,
navigationHidden, purchaseOptions, catalogPlaceMultipleObjects,
plus every `set*` writer including the ones that mutate the
data slice on user-driven selection).
- `useCatalogActions()` — imperative operations only
(openCatalogByType, toggleCatalogByType, activateNode,
openPageBy{Id,Name,OfferId}, requestOfferToMover,
selectCatalogOffer, getNodeBy{Id,Name},
getBuilderFurniPlaceableStatus).
- `useCatalog` is kept as a deprecated shim that returns the full
historical surface, so the 48 existing consumers compile and run
unchanged.
Pilot consumer migrations (3 of 48):
- `CatalogBuildersClubStatusView` — Data (furni counters, seconds
timers) + UiState (currentType).
- `CatalogBreadcrumbView` — UiState (activeNodes) + Actions
(activateNode).
- `CatalogNavigationItemView` — UiState (currentType) + Actions
(activateNode).
Tests: `tests/useCatalog.filters.test.tsx` (5 cases).
`useBetween` is mocked via `vi.hoisted` so the four hooks share one
deterministic fake store — rendering the real `useCatalogStore`
would mount ~30 useState calls + open a fresh RoomPreviewer +
subscribe to a dozen renderer events, which is more than these
contract tests need.
- `useCatalogData` exposes exactly its read-only keys.
- `useCatalogUiState` exposes exactly its UI keys + setters.
- `useCatalogActions` exposes exactly its imperative ops (and
explicitly NOT data fields — proves no leak across slices).
- Singleton identity: callbacks read through the shim are `===` to
the ones read through the slices.
- Shim surface: the historical key set is still present so
un-migrated consumers don't silently break.
Suite: 163/163 (was 158/158). `yarn typecheck` green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -259,15 +259,15 @@ into `configurePreviewServer` so `yarn preview` keeps working.
|
||||
| `useNitroQuery` + `useNitroEventInvalidator` | `OfferView`, `CatalogLayoutRoomAdsView`, `ModToolsChatlogView`, `CfhChatlogView`, `useGiftConfiguration`, `useUserGroups`, `useClubOffers(windowId)`, `useSellablePetPalette(breed)`, `useMarketplaceConfiguration`, `useClubGifts` (with invalidator) |
|
||||
| Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`) |
|
||||
| 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`, `catalog` (three-way: `useCatalogData` / `useCatalogUiState` / `useCatalogActions`, plus the `useCatalog` shim) |
|
||||
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella |
|
||||
| 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 |
|
||||
| Vitest | 163/163 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`, 34 cases on the catalog pure helpers, 5 contract cases on the catalog filters |
|
||||
| Form Actions | Login / Register / Forgot (LoginView.tsx) |
|
||||
| Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating |
|
||||
|
||||
| Not yet | Notes |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| Migrate the 48 `useCatalog()` consumers to the new filters | The split is done: pure helpers in `useCatalog.helpers.ts`, three filters (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) plus the deprecated `useCatalog` shim. Three pilot consumers already migrated (`CatalogBuildersClubStatusView`, `CatalogBreadcrumbView`, `CatalogNavigationItemView`). The remaining 45 still hit the shim — incremental work, each migration is mechanical: split the destructure into 2-3 filter calls based on which keys are read. |
|
||||
| Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. |
|
||||
| Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. |
|
||||
| Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) |
|
||||
@@ -327,6 +327,9 @@ Fix shapes documented; both are reasonable PRs on their own.
|
||||
(`buildCatalogNodeTree`, `findNodeById` / `findNodeByName`,
|
||||
`getNodesByOfferIdFromMap`, `getOfferProductKeys`,
|
||||
`normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`)
|
||||
- Catalog three-way filter split: `useCatalogData` /
|
||||
`useCatalogUiState` / `useCatalogActions` (with the deprecated
|
||||
`useCatalog` shim) in `src/hooks/catalog/useCatalog.ts`
|
||||
- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts`
|
||||
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
|
||||
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
|
||||
|
||||
+40
-4
@@ -480,9 +480,39 @@ Status after this round of work:
|
||||
| CatalogPagesList / CatalogPage | **deferred** — core state slice (rootNode / offersToNodes / currentPage), needs its own split-out store |
|
||||
| BuildersClubFurniCount / SubscriptionStatus | **deferred** — read by the internal `getBuilderFurniPlaceableStatus` logic, moves with the data/actions split |
|
||||
|
||||
Pure-helper extraction landed before the singleton split:
|
||||
`src/hooks/catalog/useCatalog.helpers.ts` hosts the dependency-free
|
||||
pieces previously inlined in the hook —
|
||||
**Helper extraction + filter split both landed.** The 1100-line hook
|
||||
now has its dependency-free logic in
|
||||
`src/hooks/catalog/useCatalog.helpers.ts` and exposes three public
|
||||
filters built on top of the same `useBetween` singleton:
|
||||
|
||||
- `useCatalogData()` — server-driven read-only slice (`rootNode`,
|
||||
`offersToNodes`, `currentPage`, `currentOffer`, `frontPageItems`,
|
||||
`searchResult`, `roomPreviewer`, `isBusy`,
|
||||
`catalogLocalizationVersion`, Builders Club counters + timers).
|
||||
- `useCatalogUiState()` — UI ephemeral state + writers
|
||||
(`isVisible`, `pageId`, `previousPageId`, `currentType`,
|
||||
`activeNodes`, `navigationHidden`, `purchaseOptions`,
|
||||
`catalogPlaceMultipleObjects`, plus all the `set*` writers,
|
||||
including the ones that mutate the data slice on page / offer /
|
||||
search-result selection).
|
||||
- `useCatalogActions()` — imperative operations
|
||||
(`openCatalogByType`, `toggleCatalogByType`, `activateNode`,
|
||||
`openPageBy{Id,Name,OfferId}`, `requestOfferToMover`,
|
||||
`selectCatalogOffer`, `getNodeBy{Id,Name}`,
|
||||
`getBuilderFurniPlaceableStatus`).
|
||||
|
||||
The internal store is named `useCatalogStore` and is **not exported**;
|
||||
the four public entry points (`useCatalogData` / `useCatalogUiState`
|
||||
/ `useCatalogActions` / `useCatalog`) all funnel into the same
|
||||
`useBetween` instance, so listeners + state register once. The
|
||||
deprecated `useCatalog` shim continues to expose the full historical
|
||||
return shape so the 48 existing consumers compile unchanged; they
|
||||
should be incrementally migrated to the specific filters as PRs
|
||||
touch them. Three pilot migrations already landed in
|
||||
`CatalogBuildersClubStatusView`, `CatalogBreadcrumbView`, and
|
||||
`CatalogNavigationItemView`.
|
||||
|
||||
Pure helpers in `useCatalog.helpers.ts`:
|
||||
|
||||
- `normalizeCatalogType(type?)` — coerce the optional catalog type
|
||||
back to `NORMAL` / `BUILDER`.
|
||||
@@ -512,7 +542,7 @@ empty-map / partial-bucket branches of the offer lookup).
|
||||
- Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom`
|
||||
configured. Separate `vitest.config.mts` so the runner doesn't drag in
|
||||
the renderer SDK aliases from `vite.config.mjs`.
|
||||
- **158 cases passing** across 11 test files. Pure-module suites:
|
||||
- **163 cases passing** across 12 test files. Pure-module suites:
|
||||
- `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
|
||||
factory.
|
||||
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
|
||||
@@ -539,6 +569,12 @@ empty-map / partial-bucket branches of the offer lookup).
|
||||
the partial-visible fallback), `buildCatalogNodeTree` (tree
|
||||
depth + offerId index), and the full decision tree of
|
||||
`resolveBuilderFurniPlaceableStatus`.
|
||||
- `useCatalog.filters.test.tsx` (5) — contract tests for the
|
||||
three-way singleton-filter split. Stubs `use-between` so the
|
||||
filters share one fake store, asserts each filter exposes
|
||||
exactly the keys it owns (no leak across slices), and pins
|
||||
down `===` identity of callbacks between the shim and each
|
||||
slice so the migration of the 48 consumers stays safe.
|
||||
|
||||
Component-/hook-level suites (on the new renderer-SDK mock):
|
||||
- `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render
|
||||
|
||||
@@ -2,11 +2,12 @@ import { GetTickerTime } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { CatalogType, FriendlyTime, LocalizeText } from '../../../../api';
|
||||
import buildersClubIcon from '../../../../assets/images/toolbar/icons/buildersclub.png';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../hooks';
|
||||
|
||||
export const CatalogBuildersClubStatusView: FC = () =>
|
||||
{
|
||||
const { currentType = CatalogType.NORMAL, furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalog();
|
||||
const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData();
|
||||
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const [ ticker, setTicker ] = useState(() => GetTickerTime());
|
||||
|
||||
useEffect(() =>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FC } from 'react';
|
||||
import { FaChevronRight, FaHome } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
import { useCatalogActions, useCatalogUiState } from '../../../../hooks';
|
||||
|
||||
export const CatalogBreadcrumbView: FC<{}> = () =>
|
||||
{
|
||||
const { activeNodes = [], activateNode } = useCatalog();
|
||||
const { activeNodes = [] } = useCatalogUiState();
|
||||
const { activateNode } = useCatalogActions();
|
||||
|
||||
if(!activeNodes || activeNodes.length === 0)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useCallback, useRef, useState } from 'react';
|
||||
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api';
|
||||
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
|
||||
import { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks';
|
||||
import { useCatalogAdmin } from '../../CatalogAdminContext';
|
||||
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
|
||||
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
|
||||
@@ -15,7 +15,8 @@ export interface CatalogNavigationItemViewProps
|
||||
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
|
||||
{
|
||||
const { node = null, child = false } = props;
|
||||
const { activateNode = null, currentType = CatalogType.NORMAL } = useCatalog();
|
||||
const { activateNode = null } = useCatalogActions();
|
||||
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
|
||||
|
||||
@@ -12,7 +12,15 @@ import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConf
|
||||
const DUMMY_PAGE_ID_FOR_OFFER_SEARCH = -12345678;
|
||||
const DRAG_AND_DROP_ENABLED = true;
|
||||
|
||||
const useCatalogState = () =>
|
||||
// Internal singleton store — held together by `useBetween` so every
|
||||
// public filter below sees the same listeners + state. Do NOT export
|
||||
// this directly; consumers must go through the filters or the
|
||||
// deprecated `useCatalog` shim. The previous 1100-line monolith
|
||||
// exposed everything via `useCatalog`; the three filters below
|
||||
// (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`)
|
||||
// shrink the surface each consumer subscribes to, which lets the
|
||||
// React Compiler memoize and avoids unrelated re-renders.
|
||||
const useCatalogStore = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ isBusy, setIsBusy ] = useState(false);
|
||||
@@ -958,4 +966,105 @@ const useCatalogState = () =>
|
||||
return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogLocalizationVersion, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus, selectCatalogOffer };
|
||||
};
|
||||
|
||||
export const useCatalog = () => useBetween(useCatalogState);
|
||||
/**
|
||||
* Read-only slice of server-driven catalog state. Anything a consumer
|
||||
* needs to *display* (page tree, current page, offers, Builders Club
|
||||
* counters) lives here.
|
||||
*
|
||||
* `roomPreviewer` and the busy flag are kept here too because they
|
||||
* are observed (not mutated) by every consumer that renders a preview.
|
||||
*/
|
||||
export const useCatalogData = () =>
|
||||
{
|
||||
const {
|
||||
isBusy,
|
||||
rootNode, offersToNodes,
|
||||
currentPage, currentOffer,
|
||||
frontPageItems, searchResult,
|
||||
roomPreviewer,
|
||||
catalogLocalizationVersion,
|
||||
furniCount, furniLimit, maxFurniLimit,
|
||||
secondsLeft, secondsLeftWithGrace, updateTime
|
||||
} = useBetween(useCatalogStore);
|
||||
|
||||
return {
|
||||
isBusy,
|
||||
rootNode, offersToNodes,
|
||||
currentPage, currentOffer,
|
||||
frontPageItems, searchResult,
|
||||
roomPreviewer,
|
||||
catalogLocalizationVersion,
|
||||
furniCount, furniLimit, maxFurniLimit,
|
||||
secondsLeft, secondsLeftWithGrace, updateTime
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* UI-side state owned by the catalog overlay itself: visibility, the
|
||||
* currently-rendered page id and breadcrumb, search query result,
|
||||
* purchase options, multi-place toggle. Includes the setters that
|
||||
* mutate the data slice when the user picks a page / offer / search
|
||||
* result — those don't trigger server traffic so they belong to the
|
||||
* UI layer.
|
||||
*/
|
||||
export const useCatalogUiState = () =>
|
||||
{
|
||||
const {
|
||||
isVisible, setIsVisible,
|
||||
pageId, previousPageId,
|
||||
currentType,
|
||||
activeNodes,
|
||||
navigationHidden, setNavigationHidden,
|
||||
purchaseOptions, setPurchaseOptions,
|
||||
catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects,
|
||||
setCurrentPage, setCurrentOffer, setSearchResult
|
||||
} = useBetween(useCatalogStore);
|
||||
|
||||
return {
|
||||
isVisible, setIsVisible,
|
||||
pageId, previousPageId,
|
||||
currentType,
|
||||
activeNodes,
|
||||
navigationHidden, setNavigationHidden,
|
||||
purchaseOptions, setPurchaseOptions,
|
||||
catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects,
|
||||
setCurrentPage, setCurrentOffer, setSearchResult
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Imperative actions: open / toggle the catalog, navigate the page
|
||||
* tree, request a furni to the mover, look up nodes by id/name, run
|
||||
* the Builders Club placement check. These all either send a
|
||||
* composer to the server, dispatch a UI event, or run synchronous
|
||||
* tree queries — none of them are React state by themselves.
|
||||
*/
|
||||
export const useCatalogActions = () =>
|
||||
{
|
||||
const {
|
||||
openCatalogByType, toggleCatalogByType,
|
||||
activateNode,
|
||||
openPageById, openPageByName, openPageByOfferId,
|
||||
requestOfferToMover, selectCatalogOffer,
|
||||
getNodeById, getNodeByName,
|
||||
getBuilderFurniPlaceableStatus
|
||||
} = useBetween(useCatalogStore);
|
||||
|
||||
return {
|
||||
openCatalogByType, toggleCatalogByType,
|
||||
activateNode,
|
||||
openPageById, openPageByName, openPageByOfferId,
|
||||
requestOfferToMover, selectCatalogOffer,
|
||||
getNodeById, getNodeByName,
|
||||
getBuilderFurniPlaceableStatus
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Deprecated. Kept so the 48 existing consumers compile unchanged —
|
||||
* incrementally migrate them to `useCatalogData` / `useCatalogUiState`
|
||||
* / `useCatalogActions` and remove this shim once the call sites are
|
||||
* gone. Mirrors the same `useBetween` singleton, so behavior is
|
||||
* identical.
|
||||
*/
|
||||
export const useCatalog = () => useBetween(useCatalogStore);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* @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 { useCatalog, useCatalogActions, useCatalogData, useCatalogUiState } from '../src/hooks/catalog/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 four hooks observe the same singleton — refs are ===', () =>
|
||||
{
|
||||
const { result } = renderHook(() =>
|
||||
({
|
||||
data: useCatalogData(),
|
||||
ui: useCatalogUiState(),
|
||||
actions: useCatalogActions(),
|
||||
full: useCatalog()
|
||||
}));
|
||||
|
||||
// The shim and the slices reach the same fakeStore. Any
|
||||
// accidental copy would break this `===` check.
|
||||
expect(result.current.full.activateNode).toBe(result.current.actions.activateNode);
|
||||
expect(result.current.full.openCatalogByType).toBe(result.current.actions.openCatalogByType);
|
||||
expect(result.current.full.setIsVisible).toBe(result.current.ui.setIsVisible);
|
||||
expect(result.current.full.setCurrentPage).toBe(result.current.ui.setCurrentPage);
|
||||
expect(result.current.full.rootNode).toBe(result.current.data.rootNode);
|
||||
expect(result.current.full.furniCount).toBe(result.current.data.furniCount);
|
||||
expect(result.current.full.roomPreviewer).toBe(result.current.data.roomPreviewer);
|
||||
});
|
||||
|
||||
it('useCatalog (deprecated shim) preserves the full historical surface', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useCatalog());
|
||||
|
||||
// Sample one field from each slice, including the setters
|
||||
// that the 48 existing consumers still destructure straight
|
||||
// out of `useCatalog()`. If a setter or callback ever stops
|
||||
// being forwarded, the shim breaks and those consumers
|
||||
// silently fail.
|
||||
const required = [
|
||||
'rootNode', 'offersToNodes', 'currentPage', 'currentOffer', 'frontPageItems',
|
||||
'isVisible', 'setIsVisible', 'pageId', 'previousPageId', 'currentType',
|
||||
'setCurrentPage', 'setCurrentOffer', 'setSearchResult',
|
||||
'openCatalogByType', 'toggleCatalogByType', 'activateNode',
|
||||
'openPageById', 'openPageByName', 'openPageByOfferId',
|
||||
'requestOfferToMover', 'selectCatalogOffer',
|
||||
'getNodeById', 'getNodeByName', 'getBuilderFurniPlaceableStatus',
|
||||
'furniCount', 'furniLimit', 'secondsLeft', 'updateTime'
|
||||
];
|
||||
|
||||
for(const key of required) expect(result.current).toHaveProperty(key);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user