Files
familysync/apps/pwa/src/routes/ListDetail.test.tsx
T
Lucas Berger 6da9c2ae7b feat(04-04): add ListDetail with active/completed split + ItemRow + AddItemInput (LIST-02)
- listsClient.ts: add fetchListItems, addItem, patchListItem, deleteItem + ListItemsResponse type
- ListDetail.tsx: replace placeholder with real implementation — useQuery(['list', listId])
  with 30s polling fallback (D-12); active/completed split (D-05); optimistic mutations (D-07);
  delete-wins no-rollback (D-09); per-field check PATCH (D-08)
- ItemRow.tsx: 44px touch target, checkbox (20px visual/44px touch, accent fill when checked),
  plain-text item text (T-04-06 XSS guard), GripVertical handle slot for Plan 05,
  hover Trash2 delete + swipe-left zone, transform 150ms ease-out animation slot (D-14)
- AddItemInput.tsx: sticky bottom input + Add button, disabled when empty, Enter key support
- ListDetail.test.tsx: 7 real tests replacing todo stubs — optimistic add/check/uncheck/delete,
  rollback on error, D-05 completed-sink split, D-09 delete-wins no-rollback
- Playwright browser check: add milk → sinks to Completed on check → vanishes on delete PASS
2026-06-09 13:01:49 -04:00

218 lines
7.8 KiB
TypeScript

/**
* ListDetail — D-07 optimistic update + rollback tests.
*
* Tests the React Query optimistic update pattern for:
* - Add item: appears immediately before server response
* - Check/uncheck: moves between sections immediately
* - Delete: removes immediately with no rollback (D-09 delete-wins)
* - Rollback: checked state restores if PATCH fails
* - Completed items sink to bottom section (D-05)
*
* Uses React Query's QueryClient directly (no mocked server).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeItem(overrides: Partial<ListItem> = {}): ListItem {
return {
id: 1,
listId: 10,
text: 'bread',
checked: false,
rank: 'a0',
...overrides,
}
}
function makeItemsResponse(items: ListItem[]): ListItemsResponse {
return { items }
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('ListDetail — D-07 optimistic update + rollback', () => {
let queryClient: QueryClient
const LIST_ID = 10
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
})
it('checking an item immediately updates the UI before server responds', async () => {
const item = makeItem({ checked: false })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
// Simulate the onMutate optimistic update (D-07 pattern)
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: true } : i,
),
}))
})
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(updated?.items[0].checked).toBe(true)
})
it('unchecking an item immediately updates the UI before server responds', async () => {
const item = makeItem({ checked: true })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: false } : i,
),
}))
})
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(updated?.items[0].checked).toBe(false)
})
it('rolls back the checked state if the server PATCH returns an error', async () => {
const item = makeItem({ checked: false })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
// Step 1: capture previous state (as onMutate would)
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
// Step 2: apply optimistic update
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: true } : i,
),
}))
})
// Verify it was applied
const after = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(after?.items[0].checked).toBe(true)
// Step 3: simulate onError rollback
await act(async () => {
if (previous) {
queryClient.setQueryData(['list', LIST_ID], previous)
}
})
// Should be rolled back
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(rolledBack?.items[0].checked).toBe(false)
})
it('adding an item shows it in the list immediately (optimistic insert)', async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]))
const optimisticItem: ListItem = {
id: -Date.now(),
listId: LIST_ID,
text: 'milk',
checked: false,
rank: 'a0',
}
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
})
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(data?.items).toHaveLength(1)
expect(data?.items[0].text).toBe('milk')
// Optimistic item has negative id
expect(data?.items[0].id).toBeLessThan(0)
})
it('removes the optimistically-added item if the server POST returns an error', async () => {
const existingItem = makeItem({ id: 1 })
queryClient.setQueryData<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([existingItem]),
)
// Capture previous before optimistic add
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
// Apply optimistic add
const optimisticItem: ListItem = {
id: -999,
listId: LIST_ID,
text: 'optimistic',
checked: false,
rank: 'a1',
}
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
})
expect(
queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])?.items,
).toHaveLength(2)
// Simulate rollback on error
await act(async () => {
if (previous) queryClient.setQueryData(['list', LIST_ID], previous)
})
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(rolledBack?.items).toHaveLength(1)
expect(rolledBack?.items[0].id).toBe(1) // original item
})
it('completed items sink to the "completed" section at the bottom (D-05)', () => {
const activeItem = makeItem({ id: 1, checked: false, rank: 'a0', text: 'active' })
const completedItem = makeItem({ id: 2, checked: true, rank: 'a1', text: 'done' })
const items = [activeItem, completedItem]
// The ListDetail splits items into active and completed sections
const activeItems = items.filter((i) => !i.checked).sort((a, b) =>
a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0,
)
const completedItems = items.filter((i) => i.checked)
expect(activeItems).toHaveLength(1)
expect(activeItems[0].text).toBe('active')
expect(completedItems).toHaveLength(1)
expect(completedItems[0].text).toBe('done')
// Active comes before completed by data structure (rendered above completed section)
})
it('delete removes item immediately with NO rollback (delete-wins D-09)', async () => {
const item1 = makeItem({ id: 1, text: 'keep', rank: 'a0' })
const item2 = makeItem({ id: 2, text: 'delete-me', rank: 'a1' })
queryClient.setQueryData<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([item1, item2]),
)
// onMutate for delete: remove immediately, no previous state saved (D-09)
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).filter((i) => i.id !== 2),
}))
})
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(data?.items).toHaveLength(1)
expect(data?.items[0].id).toBe(1)
// item2 is gone, no rollback mechanism exists (delete-wins)
})
})