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