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:
Hermes (Agent)
2026-06-24 05:36:59 +00:00
parent 81b37530e3
commit 86651b8d26
9 changed files with 354 additions and 81 deletions

View File

@@ -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)/)',
],
};
};

View File

@@ -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()
})

View File

@@ -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
View 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)
})
})

View File

@@ -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')

View File

@@ -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} />
))}
</>
)

View File

@@ -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')
})
})
})

View File

@@ -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;
}

View File

@@ -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
},
}
}