GraphSchemaEditor.test.tsx7.5 KBView on GitHub
/**
 * The schema editor — the form that replaced editing `view` as JSON.
 *
 * Every assertion here is about the CALLBACK, never about a request: the editor emits
 * `onSetView` / `onSetFields` and the canvas wires them to `graphs.setView` / `graphs.addFields`.
 * That split is the thing worth pinning — a form that reached for tRPC itself could not be
 * mounted in a test at all without a provider, and could not be reused by a read-only surface.
 *
 * The other recurring assertion is that "nothing" is emitted as `undefined` rather than as an
 * empty string. `setView` MERGES, so a stored `''` would be a key every reader downstream has
 * to remember is falsy; omitting it puts the graph back in the state it was in before anyone
 * chose.
 */

import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { GraphSchema } from '@zero/server/graph';

import { GraphSchemaEditor } from '@/modules/graph/GraphSchemaEditor';

const SCHEMA: GraphSchema = {
  version: 1,
  visibility: 'team',
  nodeSchema: {
    fields: [
      { key=[redacted], label: 'Title', type: 'text', from: 'person.title', displayOnCard: true },
      {
        key=[redacted],
        label: 'Stance',
        type: 'select',
        options: ['supporter', 'skeptic'],
        optionColors: { supporter: 'green', skeptic: 'amber' },
      },
      { key=[redacted], label: 'Seniority', type: 'select', options: ['c_level', 'vp'] },
    ],
  },
  edgeKinds: [
    { key=[redacted], label: 'Reports to' },
    { key=[redacted], label: 'Influences' },
  ],
  view: { colorField: 'stance', tierField: 'seniority', layoutEdgeKind: 'reports_to' },
};

/**
 * The control in a named row.
 *
 * Scoped to the ROW rather than queried globally because a field's label is not unique on this
 * panel — "Stance" is both the background row's value and a chip on the node face, and a test
 * that reached for either by name would pick whichever the DOM happened to order first.
 *
 * Two roles, because the panel deliberately mixes two controls: radix's `Select` presents as a
 * `combobox`, the hand-built trigger that opens the colour picker as a `button`.
 */
function rowControl(label: string): HTMLElement {
  const row = screen.getByText(label).closest('[data-settings-row]');
  if (!row) throw new Error(`No settings row labelled ${label}`);
  return within(row).queryByRole('combobox') ?? within(row).getByRole('button');
}

function renderEditor(schema: GraphSchema = SCHEMA) {
  const onSetView = jest.fn();
  const onSetFields = jest.fn();
  render(<GraphSchemaEditor schema={schema} onSetView={onSetView} onSetFields={onSetFields} />);
  return { onSetView, onSetFields };
}

describe('GraphSchemaEditor — which field paints', () => {
  it('names the field that paints, and repoints it', async () => {
    const { onSetView } = renderEditor();
    const trigger = rowControl('Background');
    expect(trigger).toHaveTextContent('Stance');

    await userEvent.click(trigger);
    await userEvent.click(await screen.findByRole('option', { name: /Seniority/ }));

    expect(onSetView).toHaveBeenCalledWith({ colorField: 'seniority' });
  });

  /**
   * The point of the picker over a `<select>`: the rows carry the colours, so the choice is
   * made by looking rather than by remembering what `stance` is worth. A field that declares
   * no colours must NOT borrow a palette — it is the one that will paint nothing.
   */
  it('draws each field’s own palette on its row', async () => {
    renderEditor();
    await userEvent.click(rowControl('Background'));

    const stance = await screen.findByRole('option', { name: /Stance/ });
    expect(stance.querySelector('.bg-green-500')).not.toBeNull();
    expect(stance.querySelector('.bg-amber-500')).not.toBeNull();

    const seniority = screen.getByRole('option', { name: /Seniority/ });
    expect(seniority.querySelector('[class*="bg-"]')).toBeNull();
    expect(seniority.querySelector('.border-dashed')).not.toBeNull();
  });

  it('clears the tint by omitting the key, never by storing an empty string', async () => {
    const { onSetView } = renderEditor();
    await userEvent.click(rowControl('Background'));
    await userEvent.click(await screen.findByRole('option', { name: /no tint/ }));

    expect(onSetView).toHaveBeenCalledWith({ colorField: undefined });
  });

  /**
   * A field an agent dropped out from under the view. Offered back, or the control matches
   * nothing, renders blank, and the graph is tinted by something the form cannot name.
   */
  it('offers back a colour field the schema no longer declares', async () => {
    renderEditor({ ...SCHEMA, view: { ...SCHEMA.view, colorField: 'buying_role' } });
    const trigger = rowControl('Background');
    expect(trigger).toHaveTextContent('buying_role');

    await userEvent.click(trigger);
    expect(await screen.findByRole('option', { name: /no longer a field/ })).toBeInTheDocument();
  });
});

describe('GraphSchemaEditor — the layout axes', () => {
  it('repoints the tier field', async () => {
    const { onSetView } = renderEditor();
    await userEvent.click(rowControl('Tiers'));
    await userEvent.click(await screen.findByRole('option', { name: 'Stance' }));

    expect(onSetView).toHaveBeenCalledWith({ tierField: 'stance' });
  });

  it('drops the tier field rather than storing a blank one', async () => {
    const { onSetView } = renderEditor();
    await userEvent.click(rowControl('Tiers'));
    await userEvent.click(await screen.findByRole('option', { name: /one row/ }));

    expect(onSetView).toHaveBeenCalledWith({ tierField: undefined });
  });

  it('repoints the hierarchy axis onto another relation', async () => {
    const { onSetView } = renderEditor();
    await userEvent.click(rowControl('Hierarchy'));
    await userEvent.click(await screen.findByRole('option', { name: 'Influences' }));

    expect(onSetView).toHaveBeenCalledWith({ layoutEdgeKind: 'influences' });
  });

  /** A row offering exactly one choice is a control that cannot be used. */
  it('hides the hierarchy row when the graph declares no relations', () => {
    renderEditor({ ...SCHEMA, edgeKinds: [], view: { colorField: 'stance' } });
    expect(screen.queryByText('Hierarchy')).toBeNull();
  });
});

describe('GraphSchemaEditor — the node face', () => {
  it('ticks a field onto the face, sending only that field', async () => {
    const { onSetFields } = renderEditor();
    await userEvent.click(screen.getByRole('button', { name: 'Stance', pressed: false }));

    expect(onSetFields).toHaveBeenCalledWith([
      {
        key=[redacted],
        label: 'Stance',
        type: 'select',
        options: ['supporter', 'skeptic'],
        optionColors: { supporter: 'green', skeptic: 'amber' },
        displayOnCard: true,
      },
    ]);
  });

  /**
   * Off, too. `addFields` merges, so the removal has to be an explicit `false` — leaving the
   * key out would merge to "unchanged" and the chip would light straight back up.
   */
  it('takes a field back off the face with an explicit false', async () => {
    const { onSetFields } = renderEditor();
    await userEvent.click(screen.getByRole('button', { name: 'Title', pressed: true }));

    expect(onSetFields).toHaveBeenCalledWith([
      expect.objectContaining({ key=[redacted], displayOnCard: false }),
    ]);
  });

  it('says so, rather than showing empty controls, when there are no fields', () => {
    renderEditor({ ...SCHEMA, nodeSchema: { fields: [] }, view: {} });
    expect(screen.getByText('This graph declares no node fields yet.')).toBeInTheDocument();
    expect(screen.queryByText('Background')).toBeNull();
  });
});