mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +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:
@@ -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