BoardDocumentView.render.test.tsx7.6 KBView on GitHub /**
* The kanban, rendered — against the schema an agent actually authored.
*
* The board type shipped with 27 component tests over its PIECES and none over the composed
* view, which the handoff called verification debt rather than known breakage. This closes the
* half that does not need a browser: mount `BoardDocumentView` with the real schema the
* customer-feedback agent wrote for itself (`status` as the axis, five values, a glyph on each)
* and the real cards it filed, and assert what appears.
*
* The remaining half — that it LOOKS right, and that a drag works — needs a browser and is
* called out in the design doc as still owed.
*/
import React from 'react';
import { render, screen, within } from '@testing-library/react';
const mockSchema = {
version: 1 as const,
visibility: 'team' as const,
fields: [
{
key=[redacted],
label: 'Status',
type: 'select',
options: ['new', 'triaged', 'in_progress', 'shipped', 'wont_do'],
optionIcons: {
new: 'inbox',
triaged: 'circle-dot',
in_progress: 'play',
shipped: 'circle-check',
wont_do: 'circle-x',
},
},
{ key=[redacted], label: 'Kind', type: 'select', options: ['bug', 'feature', 'question'] },
{ key=[redacted], label: 'Customer', type: 'text' },
{ key=[redacted], label: 'Reports', type: 'number' },
],
view: {
groupByField: 'status',
cardFields: ['kind', 'customer', 'report_count'],
columnOrder: ['new', 'triaged', 'in_progress', 'shipped', 'wont_do'],
},
};
const mockCards = [
{
id: 'c1',
path: 'organisation/boards/customer-feedback/completed-next-steps',
title: 'Completed next-steps items persist in agenda for days',
emoji: null,
description: null,
boardId: 'b1',
rank: '1000',
fields: { status: 'new', kind: 'bug', customer: 'Hey Telo', report_count: 2 },
updatedAt: new Date(0),
},
{
id: 'c2',
path: 'organisation/boards/customer-feedback/bulk-approve',
title: 'Bulk-approve morning drafts',
emoji: null,
description: null,
boardId: 'b1',
rank: '2000',
fields: { status: 'triaged', kind: 'feature', customer: 'Hey Telo' },
updatedAt: new Date(0),
},
{
// The invariant that matters most: a value the schema never offered must still draw.
id: 'c3',
path: 'organisation/boards/customer-feedback/invented',
title: 'A status the schema never declared',
emoji: null,
description: null,
boardId: 'b1',
rank: '3000',
fields: { status: 'escalated', kind: 'bug' },
updatedAt: new Date(0),
},
{
// …and so must a card with no value for the axis at all.
id: 'c4',
path: 'organisation/boards/customer-feedback/no-status',
title: 'A card with no status',
emoji: null,
description: null,
boardId: 'b1',
rank: '4000',
fields: { kind: 'question' },
updatedAt: new Date(0),
},
];
jest.mock('@/modules/documents/board/useYBoard', () => ({
useYBoard: () => ({
schema: mockSchema,
isLoading: false,
patchSchema: jest.fn(),
addField: jest.fn(),
updateField: jest.fn(),
removeField: jest.fn(),
setView: jest.fn(),
undo: jest.fn(),
redo: jest.fn(),
}),
BOARD_ORIGIN: 'board',
}));
jest.mock('@/modules/documents/yjs', () => ({ useDocEvents: () => undefined }));
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({
boards: {
listCards: {
queryKey: () => ['boards.listCards'],
queryOptions: () => ({ queryKey: ['boards.listCards'] }),
},
moveCard: { mutationOptions: () => ({}) },
addCard: { mutationOptions: () => ({}) },
},
}),
}));
jest.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
invalidateQueries: jest.fn(),
cancelQueries: jest.fn(),
getQueryData: jest.fn(),
setQueryData: jest.fn(),
}),
useQuery: () => ({ data: { cards: mockCards }, isLoading: false }),
useMutation: () => ({ mutate: jest.fn(), isPending: false }),
}));
// The ticket half is its own view with its own tRPC surface; this file is about the columns.
jest.mock('@/modules/documents/board/CardDocumentView', () => ({
CardDocumentView: () => <div data-testid="ticket" />,
}));
import { BoardDocumentView } from '@/modules/documents/board/BoardDocumentView';
describe('BoardDocumentView — the composed kanban', () => {
beforeEach(() => render(<BoardDocumentView documentId="b1" />));
/**
* The columns, in render order. Selected by each column's own "Add a card to X" control
* rather than a test id: that label is the accessible name of a real affordance, so a
* refactor that changes it changes something a user can perceive.
*/
const columnLabels = () =>
screen
.getAllByRole('button', { name: /^Add a card to / })
.map((b) => b.getAttribute('aria-label')!.replace('Add a card to ', ''));
const columnHeader = (label: string) =>
screen.getByRole('button', { name: `Add a card to ${label}` }).closest('div')!;
it('draws every declared column, in the order the view declares', () => {
expect(columnLabels().slice(0, 5)).toEqual([
'new',
'triaged',
'in_progress',
'shipped',
'wont_do',
]);
});
it('appends a column for a value the schema never offered — no card renders nowhere', () => {
expect(columnLabels()).toContain('escalated');
expect(screen.getByText('A status the schema never declared')).toBeInTheDocument();
});
it('puts a card with NO axis value in the "No value" column, and puts it LAST', () => {
expect(columnLabels().at(-1)).toBe('No value');
expect(screen.getByText('A card with no status')).toBeInTheDocument();
});
it('shows the card-face fields the view names, on the card they belong to', () => {
const cardEl = screen
.getByText('Completed next-steps items persist in agenda for days')
.closest('li, [role="button"], div');
// `closest` answers `Element | null`, and `within` needs an `HTMLElement`. A card whose
// text rendered outside any of those containers is a layout change worth failing on, not
// something to read past with a non-null assertion.
if (!(cardEl instanceof HTMLElement)) throw new Error('the card text has no element around it');
const card = cardEl;
// `view.cardFields` is ['kind','customer','report_count'] — all three, and nothing else
// from the card's bag (its `quote` and `severity` are long, and belong on the ticket).
expect(within(card).getByText('bug')).toBeInTheDocument();
expect(within(card).getByText('Hey Telo')).toBeInTheDocument();
expect(within(card).getByText('2')).toBeInTheDocument();
// Both Hey Telo cards show the customer — the field is on the face, not on one card.
expect(screen.getAllByText('Hey Telo')).toHaveLength(2);
});
it('draws the glyph the board declared, and NOTHING for a value it never offered', () => {
// The gotcha: an unrecognised icon name renders no icon at all, never a fallback glyph.
// A card showing a check because a name was misspelled reads as finished when it is not.
expect(columnHeader('shipped').querySelector('svg')).not.toBeNull();
// `escalated` is a value found only on a card, so the schema declares no glyph for it —
// the column still draws, with its label and no icon.
const escalated = columnHeader('escalated');
const icons = escalated.querySelectorAll('svg');
// The only svg in an un-glyphed header is the "+" on the add button.
expect(icons.length).toBe(1);
});
it('counts the cards in each column', () => {
expect(within(columnHeader('new')).getByText('1')).toBeInTheDocument();
expect(within(columnHeader('shipped')).getByText('0')).toBeInTheDocument();
});
});