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