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:
simoleo89
2026-05-13 21:50:56 +02:00
parent fd3ef7875d
commit 59d6c4cab3
7 changed files with 372 additions and 15 deletions
+206
View File
@@ -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);
});
});