feat(redesign): [P7] refactor boardStore to singleton + App smoke test
- boardStore.js: refactored from per-component useState to a module-level
singleton via useSyncExternalStore. All callers share state (true
singleton). Exposes __resetStore/__setState/__setPersistOnChange for
test isolation. addBoard/addList/addCard still return the new id
(computed from post-action state).
- jest.config.js: added moduleNameMapper for CSS (identity-obj-proxy)
+ transformIgnorePatterns for @dnd-kit ESM modules.
- jest.setup.js: stub window.matchMedia for AppShell, clear localStorage
+ reset store singleton before each test.
- src/test/testUtils.jsx: renderWithStore now wraps the UI in a Host
that calls useBoardStore and clones the child element to inject
{ state, actions } as props. Returns result.state/result.actions
getters so tests can read state and invoke actions from outside.
- src/components/List.test.jsx: Host accepts state/actions props and
falls back to useBoardStore for standalone use.
- src/components/ListHeader.test.jsx + Board.test.jsx: updated to new
result.state API (was store.getState() in P3's API).
- src/App.jsx: final spec wiring — sidebar + board area + TopBar + Board
+ empty states (first-run + select-board). Reads from useBoardStore,
passes state + actions down to AppShell + Board.
- src/App.test.jsx: smoke test — sidebar + topbar + board + lists render
when seeded; +Create board flow creates a board and activates it;
first-run and select-board empty states render correctly; mobile
breakpoint shows hamburger.
All 119 tests pass across 15 suites. npm run build green.
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
export default {
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
transform: {
|
||||
'^.+\\.(js|jsx)$': 'babel-jest',
|
||||
'^.+\\.(js|jsx)$': ['babel-jest', { configFile: './babel.config.js' }],
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testMatch: ['<rootDir>/src/**/*.test.{js,jsx}'],
|
||||
moduleFileExtensions: ['js', 'jsx', 'json'],
|
||||
testEnvironmentOptions: {
|
||||
// jsdom 22+ doesn't ship crypto.randomUUID by default in some envs
|
||||
customExportConditions: [''],
|
||||
},
|
||||
// Stub CSS imports as identity objects so jest doesn't try to parse them.
|
||||
moduleNameMapper: {
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@dnd-kit)/)',
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -1 +1,31 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { __resetStore } from './src/store/boardStore'
|
||||
|
||||
// jsdom doesn't implement window.matchMedia. AppShell uses it to detect
|
||||
// mobile breakpoints. Stub a no-op implementation that always reports
|
||||
// desktop (matches=false) unless a test explicitly overrides it.
|
||||
if (typeof window !== 'undefined' && !window.matchMedia) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {}, // deprecated
|
||||
removeListener: () => {}, // deprecated
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// The board store is a module-level singleton. Reset it before every test
|
||||
// so tests don't leak state into each other. localStorage is also cleared
|
||||
// because the store seeds from localStorage on first read.
|
||||
beforeEach(() => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
window.localStorage.clear()
|
||||
}
|
||||
__resetStore()
|
||||
})
|
||||
12
src/App.jsx
12
src/App.jsx
@@ -9,6 +9,13 @@ import EmptyState from './components/EmptyState'
|
||||
* Owns the singleton board store (via useBoardStore) and feeds its state +
|
||||
* actions into the prop-driven <AppShell> + <Board> tree. All persistence
|
||||
* (localStorage write) is handled inside the store hook.
|
||||
*
|
||||
* Render branches:
|
||||
* - active board present → TopBar + Board (Trello kanban surface)
|
||||
* - no active board but boardOrder is non-empty → "Select a board" empty state
|
||||
* - no boards at all → "First-run" empty state (theoretically unreachable
|
||||
* after P2's migrate() seeds an Inbox board, but kept as a defensive
|
||||
* fallback in case the store is reset to empty via __resetStore()).
|
||||
*/
|
||||
function App() {
|
||||
const { state, actions } = useBoardStore()
|
||||
@@ -28,10 +35,7 @@ function App() {
|
||||
boardOrder={state.boardOrder}
|
||||
activeBoardId={activeBoardId}
|
||||
onSelectBoard={(id) => actions.setActiveBoard(id)}
|
||||
onAddBoard={(name) => {
|
||||
// Return the new id so the sidebar can switch to it.
|
||||
return actions.addBoard(name)
|
||||
}}
|
||||
onAddBoard={(name) => actions.addBoard(name)}
|
||||
onRenameBoard={(id, name) => actions.renameBoard(id, name)}
|
||||
onDeleteBoard={(id) => actions.deleteBoard(id)}
|
||||
emptyState={emptyState}
|
||||
|
||||
150
src/App.test.jsx
Normal file
150
src/App.test.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* App — top-level smoke test.
|
||||
*
|
||||
* Verifies that the integrated App shell renders the expected major regions
|
||||
* (sidebar, top bar, board area, lists) and that the basic create-board flow
|
||||
* works end-to-end through the real store.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import App from './App'
|
||||
import { renderWithStore, makeEmptyState } from './test/testUtils.jsx'
|
||||
|
||||
const SEEDED_BOARD = 'existing'
|
||||
|
||||
function seedWithOneBoard() {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
activeBoardId: SEEDED_BOARD,
|
||||
boardOrder: [SEEDED_BOARD],
|
||||
boards: { [SEEDED_BOARD]: { id: SEEDED_BOARD, name: 'Existing', listIds: [] } },
|
||||
lists: {},
|
||||
cards: {},
|
||||
}
|
||||
}
|
||||
|
||||
function seedWithBoardAndList() {
|
||||
const boardId = 'b1'
|
||||
const listId = 'l1'
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
activeBoardId: boardId,
|
||||
boardOrder: [boardId],
|
||||
boards: { [boardId]: { id: boardId, name: 'Project', listIds: [listId] } },
|
||||
lists: { [listId]: { id: listId, boardId, name: 'Todo', cardIds: [] } },
|
||||
cards: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('<App />', () => {
|
||||
test('renders sidebar + topbar + board + lists when seeded', () => {
|
||||
renderWithStore(<App />, { initial: seedWithBoardAndList() })
|
||||
|
||||
// Sidebar + topbar
|
||||
expect(screen.getByRole('complementary', { name: /boards sidebar/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Project' })).toBeInTheDocument()
|
||||
// Board + list
|
||||
expect(screen.getByTestId('board')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('list')).toBeInTheDocument()
|
||||
expect(screen.getByText('Todo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('"+ Create board" flow creates a new board and activates it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const result = renderWithStore(<App />, { initial: seedWithOneBoard() })
|
||||
|
||||
// Click the create-board button in the sidebar
|
||||
const createBtn = screen.getByRole('button', { name: /create board/i })
|
||||
await user.click(createBtn)
|
||||
|
||||
// Type a name and submit
|
||||
const input = screen.getByRole('textbox', { name: /board name/i })
|
||||
await user.type(input, 'My new board{enter}')
|
||||
|
||||
// A new board should now exist in the store and be active.
|
||||
const boardIds = Object.keys(result.state.boards)
|
||||
expect(boardIds.length).toBe(2)
|
||||
const newBoardId = boardIds.find((id) => id !== SEEDED_BOARD)
|
||||
expect(result.state.boards[newBoardId].name).toBe('My new board')
|
||||
expect(result.state.activeBoardId).toBe(newBoardId)
|
||||
|
||||
// TopBar reflects the new active board
|
||||
expect(screen.getByRole('heading', { name: 'My new board' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('first-run branch: empty initial state shows first-run empty state + sidebar CTA', () => {
|
||||
renderWithStore(<App />, { initial: makeEmptyState() })
|
||||
|
||||
// No active board → empty state visible in main area
|
||||
expect(screen.getByText(/welcome to ultra todo/i)).toBeInTheDocument()
|
||||
|
||||
// Sidebar first-run CTA visible (since boardOrder.length === 0)
|
||||
expect(screen.getByRole('button', { name: /create your first board/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('first-run flow: "+ Create your first board" creates the first board', async () => {
|
||||
const user = userEvent.setup()
|
||||
const result = renderWithStore(<App />, { initial: makeEmptyState() })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /create your first board/i }))
|
||||
const input = screen.getByRole('textbox', { name: /board name/i })
|
||||
await user.type(input, 'First board{enter}')
|
||||
|
||||
expect(Object.keys(result.state.boards).length).toBe(1)
|
||||
const boardId = Object.keys(result.state.boards)[0]
|
||||
expect(result.state.boards[boardId].name).toBe('First board')
|
||||
expect(result.state.activeBoardId).toBe(boardId)
|
||||
})
|
||||
|
||||
test('select-board empty state appears when boards exist but none is active', () => {
|
||||
const initial = {
|
||||
schemaVersion: 2,
|
||||
activeBoardId: null,
|
||||
boardOrder: [SEEDED_BOARD],
|
||||
boards: { [SEEDED_BOARD]: { id: SEEDED_BOARD, name: 'Existing', listIds: [] } },
|
||||
lists: {},
|
||||
cards: {},
|
||||
}
|
||||
renderWithStore(<App />, { initial })
|
||||
|
||||
// Sidebar shows the existing board
|
||||
expect(screen.getByRole('button', { name: 'Existing' })).toBeInTheDocument()
|
||||
// No TopBar (no active board)
|
||||
expect(screen.queryByRole('heading', { name: 'Existing' })).not.toBeInTheDocument()
|
||||
// "Select a board" prompt
|
||||
expect(screen.getByText(/select a board from the sidebar/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('mobile breakpoint hides the sidebar by default and shows a hamburger', () => {
|
||||
// Override matchMedia to report mobile (<=768px) before render.
|
||||
window.matchMedia = (query) => ({
|
||||
matches: query === '(max-width: 768px)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})
|
||||
|
||||
const initial = {
|
||||
schemaVersion: 2,
|
||||
activeBoardId: 'b1',
|
||||
boardOrder: ['b1'],
|
||||
boards: { b1: { id: 'b1', name: 'Mobile Board', listIds: [] } },
|
||||
lists: {},
|
||||
cards: {},
|
||||
}
|
||||
renderWithStore(<App />, { initial })
|
||||
|
||||
// Sidebar is rendered but not visible (the `--open` modifier isn't applied).
|
||||
const sidebar = screen.getByRole('complementary', { name: /boards sidebar/i })
|
||||
expect(sidebar).not.toHaveClass('sidebar--open')
|
||||
|
||||
// Hamburger button(s) should be present (both in Sidebar and TopBar).
|
||||
const hamburgers = screen.getAllByRole('button', { name: /toggle sidebar/i })
|
||||
expect(hamburgers.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -39,11 +39,11 @@ describe('<Board />', () => {
|
||||
lists: {},
|
||||
cards: {},
|
||||
}
|
||||
const { store } = renderWithStore(<Board boardId={boardId} />, { initial })
|
||||
const result = renderWithStore(<Board boardId={boardId} />, { initial })
|
||||
|
||||
const before = Object.keys(store.getState().lists).length
|
||||
const before = Object.keys(result.state.lists).length
|
||||
await user.click(screen.getByRole('button', { name: /add another list/i }))
|
||||
expect(Object.keys(store.getState().lists).length).toBe(before + 1)
|
||||
expect(Object.keys(result.state.lists).length).toBe(before + 1)
|
||||
|
||||
// Inline editor (input) takes focus.
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
@@ -5,16 +5,18 @@ import { render } from '@testing-library/react'
|
||||
import { useBoardStore } from '../store/boardStore.js'
|
||||
import List from './List.jsx'
|
||||
|
||||
function Host({ listId, alsoRender }) {
|
||||
// Render the requested list (and any additional lists specified via
|
||||
// `alsoRender`, e.g. a destination list for move tests). This keeps
|
||||
// queries scoped so the assertions don't trip over duplicates.
|
||||
const { state, actions } = useBoardStore()
|
||||
function Host({ listId, alsoRender, state, actions }) {
|
||||
// Accept state + actions from a wrapping test host (the integration's
|
||||
// renderWithStore passes them via cloneElement). Fall back to a direct
|
||||
// useBoardStore call so this file can also be run standalone.
|
||||
const store = useBoardStore()
|
||||
const _state = state ?? store.state
|
||||
const _actions = actions ?? store.actions
|
||||
const listsToRender = [listId, ...(alsoRender || [])].filter(Boolean)
|
||||
return (
|
||||
<>
|
||||
{listsToRender.map((id) => (
|
||||
<List key={id} listId={id} actions={actions} state={state} />
|
||||
<List key={id} listId={id} actions={_actions} state={_state} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { screen, act } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ListHeader from './ListHeader.jsx'
|
||||
import { renderWithStore } from '../test/testUtils.jsx'
|
||||
@@ -42,51 +42,50 @@ describe('<ListHeader />', () => {
|
||||
test('typing + blur renames the list in the store', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { initial, listId } = makeInitialWithCards()
|
||||
const { store } = renderWithStore(<ListHeader listId={listId} />, { initial })
|
||||
const result = renderWithStore(<ListHeader listId={listId} />, { initial })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Backlog' }))
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Renamed')
|
||||
// Tab away to trigger blur (avoids raw DOM blur() call outside act()).
|
||||
// Tab away to trigger blur.
|
||||
await user.tab()
|
||||
|
||||
expect(store.getState().lists[listId].name).toBe('Renamed')
|
||||
expect(result.state.lists[listId].name).toBe('Renamed')
|
||||
})
|
||||
|
||||
test('Enter saves, Escape cancels the rename', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { initial, listId } = makeInitialWithCards()
|
||||
const { store } = renderWithStore(<ListHeader listId={listId} />, { initial })
|
||||
const result = renderWithStore(<ListHeader listId={listId} />, { initial })
|
||||
|
||||
// Enter saves
|
||||
await user.click(screen.getByRole('button', { name: 'Backlog' }))
|
||||
let input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Enter-name{enter}')
|
||||
expect(store.getState().lists[listId].name).toBe('Enter-name')
|
||||
expect(result.state.lists[listId].name).toBe('Enter-name')
|
||||
|
||||
// Escape cancels
|
||||
await user.click(screen.getByRole('button', { name: 'Enter-name' }))
|
||||
input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'should-not-stick{escape}')
|
||||
expect(store.getState().lists[listId].name).toBe('Enter-name')
|
||||
expect(result.state.lists[listId].name).toBe('Enter-name')
|
||||
})
|
||||
|
||||
test('menu: Delete confirms via window.confirm and removes the list + cascades cards', async () => {
|
||||
const user = userEvent.setup()
|
||||
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const { initial, listId, cardIds } = makeInitialWithCards()
|
||||
const { store } = renderWithStore(<ListHeader listId={listId} />, { initial })
|
||||
const result = renderWithStore(<ListHeader listId={listId} />, { initial })
|
||||
|
||||
// Open the ⋯ menu
|
||||
await user.click(screen.getByRole('button', { name: /list actions/i }))
|
||||
await user.click(screen.getByRole('menuitem', { name: /delete list/i }))
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(store.getState().lists[listId]).toBeUndefined()
|
||||
cardIds.forEach((cid) => expect(store.getState().cards[cid]).toBeUndefined())
|
||||
expect(result.state.lists[listId]).toBeUndefined()
|
||||
cardIds.forEach((cid) => expect(result.state.cards[cid]).toBeUndefined())
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
@@ -95,12 +94,12 @@ describe('<ListHeader />', () => {
|
||||
const user = userEvent.setup()
|
||||
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { initial, listId } = makeInitialWithCards()
|
||||
const { store } = renderWithStore(<ListHeader listId={listId} />, { initial })
|
||||
const result = renderWithStore(<ListHeader listId={listId} />, { initial })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /list actions/i }))
|
||||
await user.click(screen.getByRole('menuitem', { name: /delete list/i }))
|
||||
|
||||
expect(store.getState().lists[listId]).toBeDefined()
|
||||
expect(result.state.lists[listId]).toBeDefined()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -113,4 +112,4 @@ describe('<ListHeader />', () => {
|
||||
await user.click(screen.getByRole('menuitem', { name: /rename list/i }))
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Backlog')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,32 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useSyncExternalStore, useCallback, useRef } from 'react';
|
||||
import { migrate, V2_STORAGE_KEY } from '../lib/migrate';
|
||||
|
||||
// Module-level singleton state + listeners. All calls to useBoardStore()
|
||||
// share the same state — actions on one component propagate to all
|
||||
// subscribers. This is the standard useSyncExternalStore pattern.
|
||||
let _state = null;
|
||||
let _persistOnChange = true;
|
||||
const _listeners = new Set();
|
||||
|
||||
function getState() {
|
||||
if (_state === null) {
|
||||
_state = migrate();
|
||||
persist(_state);
|
||||
}
|
||||
return _state;
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
_listeners.add(listener);
|
||||
return () => _listeners.delete(listener);
|
||||
}
|
||||
|
||||
function setState(next) {
|
||||
_state = typeof next === 'function' ? next(_state) : next;
|
||||
if (_persistOnChange) persist(_state);
|
||||
_listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
// Pure action helpers — each returns a new state object. No mutation, no async.
|
||||
// Keeping them outside the component avoids stale-closure traps with useState's
|
||||
// functional updater and makes every transition trivially testable in isolation.
|
||||
@@ -235,42 +261,42 @@ function reducer(state, action) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook — subscribes to the module-level singleton store and returns
|
||||
* `{ state, actions }`. All callers share the same state (true singleton).
|
||||
*/
|
||||
export function useBoardStore() {
|
||||
// Seed from migration synchronously so the first render already has data.
|
||||
const [state, setState] = useState(() => migrate());
|
||||
const state = useSyncExternalStore(subscribe, getState, getState);
|
||||
// Lazy-init the actions object so it doesn't churn on every render.
|
||||
const actionsRef = useRef(null);
|
||||
if (actionsRef.current === null) {
|
||||
actionsRef.current = makeActions();
|
||||
}
|
||||
return { state, actions: actionsRef.current };
|
||||
}
|
||||
|
||||
const dispatch = useCallback((action) => {
|
||||
function makeActions() {
|
||||
function dispatch(action) {
|
||||
setState((prev) => reducer(prev, action));
|
||||
}
|
||||
function dispatchAndCaptureId(action) {
|
||||
let capturedId = null;
|
||||
setState((prev) => {
|
||||
const next = reducer(prev, action);
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Compute-from-state helpers for create actions so callers (e.g. Sidebar)
|
||||
// can get the new id without us having to thread it back through dispatch.
|
||||
// Returns the newly-created id by inspecting the post-action state.
|
||||
const dispatchAndCaptureId = useCallback((action) => {
|
||||
let capturedId = null
|
||||
setState((prev) => {
|
||||
const next = reducer(prev, action);
|
||||
persist(next);
|
||||
// Compute id: for addBoard, the new id is the last in boardOrder.
|
||||
if (action.type === 'addBoard') {
|
||||
capturedId = next.boardOrder[next.boardOrder.length - 1] ?? null
|
||||
capturedId = next.boardOrder[next.boardOrder.length - 1] ?? null;
|
||||
} else if (action.type === 'addList') {
|
||||
const board = next.boards[action.boardId]
|
||||
capturedId = board ? board.listIds[board.listIds.length - 1] ?? null : null
|
||||
const board = next.boards[action.boardId];
|
||||
capturedId = board ? board.listIds[board.listIds.length - 1] ?? null : null;
|
||||
} else if (action.type === 'addCard') {
|
||||
const list = next.lists[action.listId]
|
||||
capturedId = list ? list.cardIds[list.cardIds.length - 1] ?? null : null
|
||||
const list = next.lists[action.listId];
|
||||
capturedId = list ? list.cardIds[list.cardIds.length - 1] ?? null : null;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return capturedId;
|
||||
}, []);
|
||||
|
||||
const actions = {
|
||||
}
|
||||
return {
|
||||
addBoard: (name) => dispatchAndCaptureId({ type: 'addBoard', name }),
|
||||
renameBoard: (id, name) => dispatch({ type: 'renameBoard', id, name }),
|
||||
deleteBoard: (id) => dispatch({ type: 'deleteBoard', id }),
|
||||
@@ -284,6 +310,17 @@ export function useBoardStore() {
|
||||
deleteCard: (cardId) => dispatch({ type: 'deleteCard', cardId }),
|
||||
moveCard: (cardId, toListId, toIndex) => dispatch({ type: 'moveCard', cardId, toListId, toIndex }),
|
||||
};
|
||||
|
||||
return { state, actions };
|
||||
}
|
||||
|
||||
// Internal — exposed for advanced wiring (tests, alternate persistence).
|
||||
export function __resetStore() {
|
||||
_state = null;
|
||||
_listeners.clear();
|
||||
}
|
||||
export function __setState(s) {
|
||||
_state = s;
|
||||
_listeners.forEach((l) => l());
|
||||
}
|
||||
export function __setPersistOnChange(v) {
|
||||
_persistOnChange = v;
|
||||
}
|
||||
@@ -1,24 +1,23 @@
|
||||
/**
|
||||
* Test utilities for the redesign components.
|
||||
*
|
||||
* P2's boardStore is a singleton: useBoardStore() returns the same
|
||||
* `{ state, actions }` everywhere. On first call, its useState initializer
|
||||
* runs migrate() which reads from localStorage and either returns the v2
|
||||
* state or migrates from v1.
|
||||
* P2's boardStore is a singleton-style hook: useBoardStore() reads from
|
||||
* localStorage in its useState initializer and returns `{ state, actions }`.
|
||||
* Each call to the hook in a render tree creates its own state (useState
|
||||
* is per-hook-call), so tests are naturally isolated as long as localStorage
|
||||
* is patched BEFORE the hook runs.
|
||||
*
|
||||
* For tests, we want each test to start with a known state shape. The
|
||||
* `renderWithStore` helper below swaps `window.localStorage` with an
|
||||
* in-memory shim, optionally pre-populated with an `initial` state, so
|
||||
* the store's initializer picks it up. After render, localStorage is
|
||||
* restored.
|
||||
*
|
||||
* Tests that need to drive the store's actions directly (rather than via
|
||||
* UI events) can call `store.actions.foo()` — `store` is the same
|
||||
* singleton, so calls from inside the test tree propagate to all
|
||||
* subscribed components.
|
||||
* `renderWithStore` does two things:
|
||||
* 1. Pre-seeds localStorage with `initial` (so useBoardStore's
|
||||
* initializer picks it up via migrate()).
|
||||
* 2. Wraps the UI in a Host component that calls useBoardStore() and
|
||||
* clones the rendered child element to inject `{ state, actions }`
|
||||
* as props. This matches how App.jsx wires the store in production.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import { cloneElement } from 'react'
|
||||
import { useBoardStore } from '../store/boardStore'
|
||||
|
||||
function makeMemoryStorage() {
|
||||
const data = {}
|
||||
@@ -44,13 +43,48 @@ export function makeEmptyState() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host that calls useBoardStore and passes { state, actions } as a render prop.
|
||||
* `children` may be a function `(api) => ReactElement` OR a ReactElement —
|
||||
* in the latter case, the host uses React.cloneElement to inject state/actions.
|
||||
*/
|
||||
function Host({ children }) {
|
||||
const { state, actions } = useBoardStore()
|
||||
if (typeof children === 'function') {
|
||||
return children({ state, actions })
|
||||
}
|
||||
// Treat anything with a .type property as a React element. We avoid
|
||||
// importing isValidElement from React 19 (it's a named export there but
|
||||
// the resolution path differs across jest module setups).
|
||||
if (children && typeof children === 'object' && 'type' in children) {
|
||||
return cloneElement(children, { state, actions })
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a component tree with the singleton board store seeded from `initial`.
|
||||
*
|
||||
* @param {React.ReactElement} ui - the component to render
|
||||
* The UI can either be:
|
||||
* - a React element that accepts `{ state, actions }` props directly
|
||||
* (e.g. `<List listId="l1" />`) — the host will inject them via cloneElement.
|
||||
* - a function `(api) => ReactElement` — the host calls it with the
|
||||
* store API (used when the test wants more control, e.g. host components
|
||||
* that need access to state inside the render prop).
|
||||
*
|
||||
* Returns the @testing-library/react render result, plus a `state` and
|
||||
* `actions` getter pair so tests can read state and invoke actions from
|
||||
* outside the component tree:
|
||||
*
|
||||
* const result = renderWithStore(<List listId="l1" />, { initial })
|
||||
* expect(result.state.lists['l1'].name).toBe('Foo')
|
||||
* act(() => { result.actions.renameList('l1', 'Bar') })
|
||||
* expect(screen.getByText('Bar')).toBeInTheDocument()
|
||||
*
|
||||
* @param {React.ReactElement|Function} ui
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.initial] - v2 state shape to pre-populate
|
||||
* @returns the @testing-library/react render result
|
||||
* @returns the @testing-library/react render result + state/actions getters
|
||||
*/
|
||||
export function renderWithStore(ui, { initial } = {}) {
|
||||
const memStorage = makeMemoryStorage()
|
||||
@@ -64,17 +98,30 @@ export function renderWithStore(ui, { initial } = {}) {
|
||||
value: memStorage,
|
||||
})
|
||||
|
||||
// Import lazily so the singleton is created AFTER localStorage is patched.
|
||||
// This is important: useBoardStore.js reads window.localStorage at module
|
||||
// load time. We must therefore reset the module registry between tests so
|
||||
// each test gets a fresh singleton. Jest's `jest.resetModules()` does this
|
||||
// — callers (or jest.config.js's `resetModules: true`) must opt in.
|
||||
const result = render(ui)
|
||||
// Use a ref-like object to capture the live store API from inside Host.
|
||||
const capture = { current: null }
|
||||
function CapturingHost() {
|
||||
const api = useBoardStore()
|
||||
capture.current = api
|
||||
return <Host>{ui}</Host>
|
||||
}
|
||||
|
||||
const result = render(<CapturingHost />)
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: original,
|
||||
})
|
||||
|
||||
return result
|
||||
// Expose getters that always reflect the latest store state (which is
|
||||
// updated by useState inside the CapturingHost instance).
|
||||
return {
|
||||
...result,
|
||||
get state() {
|
||||
return capture.current ? capture.current.state : null
|
||||
},
|
||||
get actions() {
|
||||
return capture.current ? capture.current.actions : null
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user