composerActionButton.test.tsx8.3 KBView on GitHub
import { fireEvent, render, screen } from '@testing-library/react';

import {
  ComposerActionButton,
  type ComposerAction,
} from '@/modules/cedar-os/src/cedar-os-components/chatInput/ComposerActionButton';

/**
 * The send glyph shipped broken — a shaft with the arrowhead flung off to one side — because the
 * bars were rotated with a CSS transform. On an SVG child that needs `transform-box` to say what
 * the origin is measured against, and framer-motion picks that for you: `buildSVGAttrs` writes
 * `transformBox = styleProp?.transformBox ?? "fill-box"`. Under `fill-box` a viewBox-unit
 * `transform-origin` is measured from each rect's OWN bounding box, every bar turns about the
 * wrong point, and the head lands nowhere near the shaft.
 *
 * It then shipped again with `stop` reading as `queue` — three separate strips instead of one
 * square — because the geometry was animated and the in-between of two poses is a glyph that
 * means nothing.
 *
 * These pin both fixes: the rotation must be an SVG attribute with the pivot written into it,
 * there must be no CSS transform anywhere to disagree with it, every pose must be final geometry
 * the moment it renders, and the send pose must actually put the barbs corner-to-tip.
 */

/** Apply `rotate(deg cx cy)` to a point, the way the renderer will. */
function rotate(
  [x, y]: readonly [number, number],
  deg: number,
  [cx, cy]: readonly [number, number],
): [number, number] {
  const rad = (deg * Math.PI) / 180;
  const [dx, dy] = [x - cx, y - cy];
  return [
    cx + dx * Math.cos(rad) - dy * Math.sin(rad),
    cy + dx * Math.sin(rad) + dy * Math.cos(rad),
  ];
}

/** The bars as the DOM actually holds them: the rect's centreline ends, plus its group's turn. */
function bars(container: HTMLElement) {
  return Array.from(container.querySelectorAll('rect')).map((rect) => {
    const num = (name: string) => Number(rect.getAttribute(name));
    const [x, y, w, h] = [num('x'), num('y'), num('width'), num('height')];
    const group = rect.closest('g');
    const match = /rotate\(([-\d.]+) ([-\d.]+) ([-\d.]+)\)/.exec(
      group?.getAttribute('transform') ?? '',
    );
    return {
      rect,
      group,
      angle: Number(match?.[1]),
      pivot: [Number(match?.[2]), Number(match?.[3])] as const,
      // Centreline, so a rounded end does not shift the endpoint we compare.
      ends: [
        [x, y + h / 2],
        [x + w, y + h / 2],
      ] as const,
    };
  });
}

const round = ([x, y]: [number, number]) => [Math.round(x * 100) / 100, Math.round(y * 100) / 100];

const ACTIONS: ComposerAction[] = ['send', 'stop', 'queue'];

describe('the composer action button', () => {
  it('rotates with an SVG attribute and nothing else', () => {
    const { container } = render(<ComposerActionButton action="send" onClick={() => {}} />);

    const drawn = bars(container);
    expect(drawn).toHaveLength(3);

    for (const bar of drawn) {
      // The rotation is on the group, in user units, with its pivot spelled out.
      expect(bar.group?.getAttribute('transform')).toMatch(/^rotate\(-?\d+(\.\d+)? 12 12\)$/);
      // …and NOT a CSS transform, which is the whole bug. `transform-box`/`transform-origin`
      // must not appear either: their presence means something is resolving an origin again.
      expect(bar.rect.style.transform).toBe('');
      expect(bar.rect.style.transformOrigin).toBe('');
      expect(bar.rect.style.transformBox).toBe('');
      expect(bar.rect.getAttribute('transform')).toBeNull();
    }
  });

  it('turns every bar about the one pivot, so no pose can rotate about another pose’s centre', () => {
    for (const action of ['send', 'stop', 'queue'] as const) {
      const { container, unmount } = render(
        <ComposerActionButton action={action} onClick={() => {}} />,
      );
      for (const bar of bars(container)) expect(bar.pivot).toEqual([12, 12]);
      unmount();
    }
  });

  it('lands the send pose corner-to-tip: (5,12) → (12,5) → (19,12)', () => {
    const { container } = render(<ComposerActionButton action="send" onClick={() => {}} />);
    const [left, shaft, right] = bars(container);

    // Each barb runs from a bottom corner up to the tip; the shaft runs bottom to tip.
    expect(left!.ends.map((p) => round(rotate(p, left!.angle, left!.pivot)))).toEqual([
      [5, 12],
      [12, 5],
    ]);
    expect(shaft!.ends.map((p) => round(rotate(p, shaft!.angle, shaft!.pivot)))).toEqual([
      [12, 19],
      [12, 5],
    ]);
    expect(right!.ends.map((p) => round(rotate(p, right!.angle, right!.pivot)))).toEqual([
      [12, 5],
      [19, 12],
    ]);
  });

  it('switches to a pose\u2019s FINAL geometry, with no in-between that means something else', () => {
    // The reported bug: streaming with an empty composer showed the queue's three separated
    // strips (in red) instead of the stop square, because the bars were mid-flight between two
    // poses. Re-rendering must land the new pose outright.
    const { container, rerender } = render(
      <ComposerActionButton action="queue" onClick={() => {}} />,
    );
    rerender(<ComposerActionButton action="stop" onClick={() => {}} />);

    const drawn = bars(container);
    // Every bar is the stop bar: full width, the fat thickness, flush left.
    for (const bar of drawn) {
      expect(Number(bar.rect.getAttribute('width'))).toBe(14);
      expect(Number(bar.rect.getAttribute('height'))).toBe(6.2);
      expect(Number(bar.rect.getAttribute('x'))).toBe(5);
    }
    // …and the three together cover 5→19 in both axes with no gap: a square, not a stack.
    const tops = drawn.map((bar) => Number(bar.rect.getAttribute('y')));
    expect(Math.min(...tops)).toBe(5);
    expect(Math.max(...tops) + 6.2).toBeCloseTo(19);
  });

  it('collapses into one square for stop, with the bars overlapping their corner radius', () => {
    const { container } = render(<ComposerActionButton action="stop" onClick={() => {}} />);
    const drawn = bars(container);

    for (const bar of drawn) expect(bar.angle).toBe(0);

    const rows = drawn.map((bar) => {
      const y = Number(bar.rect.getAttribute('y'));
      return { top: y, bottom: y + Number(bar.rect.getAttribute('height')) };
    });
    // Each bar reaches past the next one's top by more than the 1.2 corner radius, which is what
    // buries the rounded inner corners and makes the three read as one shape.
    expect(rows[0]!.bottom - rows[1]!.top).toBeGreaterThan(1.2);
    expect(rows[1]!.bottom - rows[2]!.top).toBeGreaterThan(1.2);
    expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Stop generating');
  });

  /**
   * The glyph is the same three bars in all three poses, so it cannot say on its own which job
   * the button is about to do. The accessible name is what carries that, and the click has to
   * route the same way whichever pose is showing.
   */
  it('names each job in its label', () => {
    const labels: Record<ComposerAction, string> = {
      send: 'Send message',
      stop: 'Stop generating',
      queue: 'Queue message',
    };
    for (const action of ACTIONS) {
      const { unmount } = render(<ComposerActionButton action={action} onClick={jest.fn()} />);
      expect(screen.getByRole('button', { name: labels[action] })).toBeInTheDocument();
      unmount();
    }
  });

  it('fires onClick in every pose', () => {
    const onClick = jest.fn();
    const { rerender } = render(<ComposerActionButton action="send" onClick={onClick} />);
    fireEvent.click(screen.getByRole('button'));
    rerender(<ComposerActionButton action="stop" onClick={onClick} />);
    fireEvent.click(screen.getByRole('button'));
    rerender(<ComposerActionButton action="queue" onClick={onClick} />);
    fireEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledTimes(3);
  });

  it('swallows the click when disabled', () => {
    const onClick = jest.fn();
    render(<ComposerActionButton action="send" onClick={onClick} disabled />);
    fireEvent.click(screen.getByRole('button'));
    expect(onClick).not.toHaveBeenCalled();
  });

  it('renders the same three bars in every pose, so the glyph morphs rather than swaps', () => {
    const { container, rerender } = render(
      <ComposerActionButton action="send" onClick={jest.fn()} />,
    );
    for (const action of ACTIONS) {
      rerender(<ComposerActionButton action={action} onClick={jest.fn()} />);
      expect(container.querySelectorAll('rect')).toHaveLength(3);
    }
  });
});