DocumentProperties.test.tsx10.1 KBView on GitHub
/**
 * The document properties rail — what it DRAWS for a given (schema, values) pair.
 *
 * Mounted directly, like `CardFieldList.test.tsx`: the whole point of this component is which
 * rows appear, in which groups, in which order, and a view harness would add a tRPC query and a
 * Y.js provider around that one assertion.
 *
 * One case here is structural rather than cosmetic. Openness — a document with no schema still
 * shows everything it carries, and an unknown type says it fell back rather than hiding — is
 * the same rule the board applies, and a rail that quietly dropped either would let a reader
 * conclude an agent wrote nothing.
 */

import { fireEvent, render, screen } from '@testing-library/react';
import type { BoardField } from '@zero/server/board';

import {
  buildPropertyGroups,
  type GraphPropertyContext,
} from '@/modules/documents/properties/property-groups';
import { DocumentProperties } from '@/modules/documents/properties/DocumentProperties';

const OWNER: BoardField = { key=[redacted], label: 'Owner', type: 'text' };

/** The document's own bag — `documents.metadata.fields`, the same on every graph. */
const SUBJECT = { schema: [OWNER], fields: { owner: 'Jesse' } };

const BOUND: GraphPropertyContext['boundFields'] = [
  {
    field: { key=[redacted], label: 'Title', type: 'text' },
    value: 'VP Engineering',
  },
];

const ORG_CHART: GraphPropertyContext = {
  graphName: 'Acme org chart',
  schema: [{ key=[redacted], label: 'Stance', type: 'text' }],
  fields: { stance: 'champion' },
  boundFields: BOUND,
};

const BUYING_COMMITTEE: GraphPropertyContext = {
  graphName: 'Acme buying committee',
  schema: [{ key=[redacted], label: 'Buying role', type: 'text' }],
  fields: { buying_role: 'economic buyer' },
  boundFields: BOUND,
};

/** Where each caption lands in the rendered text — document order, read off the DOM. */
function order(container: HTMLElement, ...captions: string[]): number[] {
  const text = container.textContent ?? '';
  return captions.map((caption) => text.indexOf(caption));
}

describe('DocumentProperties — openness', () => {
  it('renders EVERY key a document carries when it declares no schema at all', () => {
    // The degenerate case of the board's rule: a UI that hides unknown keys lets a reader
    // conclude the agent wrote nothing, and then "fix" it by writing a schema without them.
    render(
      <DocumentProperties
        subject={{ fields: { headcount: '240', segment: 'mid-market', mood: 'warm' } }}
      />,
    );

    expect(screen.getByText('Not declared')).toBeInTheDocument();
    for (const [key, value] of [
      ['headcount', '240'],
      ['segment', 'mid-market'],
      ['mood', 'warm'],
    ]) {
      expect(screen.getByText(key)).toBeInTheDocument();
      expect(screen.getByText(value)).toBeInTheDocument();
    }
  });

  it('renders a declared field the document has no value for, rather than skipping it', () => {
    render(
      <DocumentProperties
        subject={{
          schema: [OWNER, { key=[redacted], label: 'Renewal', type: 'date' }],
          fields: { owner: 'Jesse' },
        }}
      />,
    );

    expect(screen.getByText('Renewal')).toBeInTheDocument();
    expect(screen.getByText('Empty')).toBeInTheDocument();
  });

  it('gives a typed key its own control — a select is a pill, a url is a link', () => {
    render(
      <DocumentProperties
        subject={{
          schema: [
            { key=[redacted], label: 'Stage', type: 'select', options: ['discovery', 'closed'] },
            { key=[redacted], label: 'Site', type: 'url' },
          ],
          fields: { stage: 'discovery', site: 'https://acme.test' },
        }}
      />,
    );

    expect(screen.getByText('discovery')).toBeInTheDocument();
    expect(screen.getByRole('link')).toHaveAttribute('href', 'https://acme.test');
  });

  it('falls back to text for an unknown type and SAYS so, rather than hiding the field', () => {
    // An agent that invented `type: 'sentiment'` gets a working text field today. Naming the
    // fallback is what stops someone "fixing" the field by deleting it.
    render(
      <DocumentProperties
        subject={{
          schema: [{ key=[redacted], label: 'Mood', type: 'sentiment' }],
          fields: { mood: 'frustrated' },
        }}
      />,
    );

    expect(screen.getByText('Mood')).toBeInTheDocument();
    expect(screen.getByText('frustrated')).toBeInTheDocument();
    expect(screen.getByText('sentiment · as text')).toBeInTheDocument();
  });

  it('opens a value in place and commits it — the value IS the control', () => {
    const onChange = jest.fn();
    render(<DocumentProperties subject={{ ...SUBJECT, onChange }} />);

    fireEvent.click(screen.getByRole('button', { name: /Jesse/ }));
    const input = screen.getByLabelText('Owner');
    fireEvent.change(input, { target: { value: 'Ashkon' } });
    fireEvent.keyDown(input, { key=[redacted] });

    expect(onChange).toHaveBeenCalledWith('owner', 'Ashkon');
  });

  it('renders read-only with no onChange — the rows are there but do not open', () => {
    render(<DocumentProperties subject={SUBJECT} />);
    expect(screen.getByRole('button', { name: /Jesse/ })).toBeDisabled();
  });
});

describe('DocumentProperties — opened from a graph', () => {
  it('shows two groups in order: the document, then this graph', () => {
    const { container } = render(<DocumentProperties subject={SUBJECT} graphContext={ORG_CHART} />);

    const [own, graph] = order(container, 'Properties', 'In Acme org chart');
    expect(own).toBeGreaterThanOrEqual(0);
    expect(graph).toBeGreaterThan(own);
    // A resolved value is a row of the graph's group, not a third block with a caption naming
    // the mechanism that produced it.
    expect(screen.queryByText('Resolved elsewhere')).not.toBeInTheDocument();
    expect(screen.getByText('VP Engineering')).toBeInTheDocument();
  });

  it('differs between two graphs over one document in the MIDDLE group alone', () => {
    // A node's fields are per-graph: `stance` on an org chart and `buying_role` on a buying
    // committee are different judgements about one human. The document and the resolved values
    // are the same person either way, and must not move.
    const { rerender } = render(<DocumentProperties subject={SUBJECT} graphContext={ORG_CHART} />);
    expect(screen.getByText('Stance')).toBeInTheDocument();
    expect(screen.queryByText('Buying role')).not.toBeInTheDocument();

    rerender(<DocumentProperties subject={SUBJECT} graphContext={BUYING_COMMITTEE} />);

    expect(screen.getByText('In Acme buying committee')).toBeInTheDocument();
    expect(screen.getByText('Buying role')).toBeInTheDocument();
    expect(screen.queryByText('Stance')).not.toBeInTheDocument();
    // Unchanged on both sides of the middle group.
    expect(screen.getByText('Owner')).toBeInTheDocument();
    expect(screen.getByText('Jesse')).toBeInTheDocument();
    expect(screen.getByText('Title')).toBeInTheDocument();
    expect(screen.getByText('VP Engineering')).toBeInTheDocument();
  });

  it('makes a resolved ROW read-only inside an otherwise editable group', () => {
    // A bound field is never written into `fields`, so offering an editor here would write into
    // a bag the next read ignores — and now that it shares a group with authored fields, that
    // has to hold per ROW rather than per group.
    render(
      <DocumentProperties
        subject={{ ...SUBJECT, onChange: jest.fn() }}
        graphContext={{ ...ORG_CHART, onChange: jest.fn() }}
      />,
    );

    expect(screen.getByRole('button', { name: /VP Engineering/ })).toBeDisabled();
    expect(screen.getByRole('button', { name: /Jesse/ })).not.toBeDisabled();
    expect(screen.getByRole('button', { name: /champion/ })).not.toBeDisabled();
  });

  it('draws an EDGE with the same component, typed by its kind when the kind declares fields', () => {
    // An edge needs no mechanism of its own: its kind's `fields` are the schema, its bag is the
    // values. One properties idiom, not two.
    render(
      <DocumentProperties
        subject={{
          label: 'Reports to',
          schema: [{ key=[redacted], label: 'Since', type: 'date' }],
          fields: { since: '2024-02-01', dotted: 'true' },
        }}
      />,
    );

    expect(screen.getByText('Reports to')).toBeInTheDocument();
    expect(screen.getByText('2024-02-01')).toBeInTheDocument();
    // An undeclared key on an edge follows the same rule it follows on a document.
    expect(screen.getByText('Not declared')).toBeInTheDocument();
    expect(screen.getByText('dotted')).toBeInTheDocument();
  });
});

describe('buildPropertyGroups', () => {
  it('omits the graph groups entirely when there is no graph context', () => {
    const groups = buildPropertyGroups({ subject: SUBJECT });
    expect(groups.map((group) => group.id)).toEqual(['own']);
  });

  it('renders no empty rail at all — a caption over nothing is furniture', () => {
    const { container } = render(<DocumentProperties subject={{ schema: [], fields: {} }} />);
    expect(container).toBeEmptyDOMElement();
  });

  it('keeps the graph group when nothing resolved — there is no third group to omit', () => {
    const groups = buildPropertyGroups({
      subject: SUBJECT,
      graph: { graphName: 'Acme org chart', schema: [], fields: { stance: 'champion' } },
    });
    expect(groups.map((group) => group.id)).toEqual(['own', 'graph']);
  });

  it('never renders a bound key twice when a cached copy is left in the graph bag', () => {
    // `derived` is a read cache, so a stale copy of a bound key can sit in `fields`. It must not
    // surface as an editable "not declared" row beside the read-only one it duplicates.
    const groups = buildPropertyGroups({
      subject: SUBJECT,
      graph: { ...ORG_CHART, fields: { stance: 'champion', title: 'Director' } },
    });
    const graphGroup = groups.find((group) => group.id === 'graph');
    expect(graphGroup?.rows.map((row) => row.field.key)).toEqual(['stance', 'title']);
    expect(graphGroup?.rows.map((row) => row.readOnly)).toEqual([undefined, true]);
    // The editable copy is the one that lost: `title` renders once, and as the resolved value.
    expect(graphGroup?.rows.find((row) => row.field.key === 'title')?.value).toBe('VP Engineering');
  });
});