Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
210 lines
7.7 KiB
TypeScript
210 lines
7.7 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, beforeEach } from 'vitest';
|
|
import { 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', () => {
|
|
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
|
|
act(() => {
|
|
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
|
|
act(() => {
|
|
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)', () => {
|
|
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]));
|
|
|
|
const optimisticItem: ListItem = {
|
|
id: -Date.now(),
|
|
listId: LIST_ID,
|
|
text: 'milk',
|
|
checked: false,
|
|
rank: 'a0',
|
|
};
|
|
|
|
act(() => {
|
|
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', () => {
|
|
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',
|
|
};
|
|
|
|
act(() => {
|
|
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
|
|
act(() => {
|
|
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)
|
|
});
|
|
});
|