From 59d6c4cab3a2ec974ec73a4ed898367f05c886b1 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:50:56 +0200 Subject: [PATCH] catalog: three-way singleton-filter split + first 3 consumer migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 9 +- docs/ARCHITECTURE.md | 44 +++- .../CatalogBuildersClubStatusView.tsx | 5 +- .../navigation/CatalogBreadcrumbView.tsx | 5 +- .../navigation/CatalogNavigationItemView.tsx | 5 +- src/hooks/catalog/useCatalog.ts | 113 +++++++++- tests/useCatalog.filters.test.tsx | 206 ++++++++++++++++++ 7 files changed, 372 insertions(+), 15 deletions(-) create mode 100644 tests/useCatalog.filters.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 20b8ee8..63dfff2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` / diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 345c0e5..8eafc58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx b/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx index 769118f..c36c78d 100644 --- a/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx +++ b/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx @@ -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(() => diff --git a/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx b/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx index 60ed73a..f677606 100644 --- a/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx +++ b/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx @@ -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) { diff --git a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx index ffeba3a..73bd63e 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx @@ -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 = 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(); diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 0f21028..67d3054 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -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); diff --git a/tests/useCatalog.filters.test.tsx b/tests/useCatalog.filters.test.tsx new file mode 100644 index 0000000..a44eb56 --- /dev/null +++ b/tests/useCatalog.filters.test.tsx @@ -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); + }); +});