mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
27791a6fba
* feat(editor): add compact plan navigator * feat(editor): simplify compact plan chrome * fix(editor): close compact navigator after file selection * feat(editor): add compact plan review surfaces * fix(editor): hold navigator through cold file loads * fix(editor): preserve desktop diff activation
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
/**
|
||
* Consumer-surface contract for AnnotationPanel's host props:
|
||
* - readOnly hides every mutation affordance (delete/edit on all card kinds)
|
||
* - renderCardFooter renders a per-card slot whose interactions do NOT
|
||
* select the card
|
||
* - embedded presentation leaves stage geometry and chrome to its host
|
||
* All default to today's behavior (mutable panel, no footer).
|
||
*/
|
||
import { afterEach, describe, expect, test } from 'bun:test';
|
||
import React from 'react';
|
||
import { createRoot, type Root } from 'react-dom/client';
|
||
import { act } from 'react';
|
||
|
||
import { AnnotationPanel } from './AnnotationPanel';
|
||
import { AnnotationType, type Annotation } from '../types';
|
||
|
||
const hasDom = typeof document !== 'undefined';
|
||
|
||
const annotation: Annotation = {
|
||
id: 'a1',
|
||
blockId: 'b1',
|
||
startOffset: 0,
|
||
endOffset: 5,
|
||
type: AnnotationType.COMMENT,
|
||
text: 'a note',
|
||
originalText: 'hello',
|
||
createdA: 1,
|
||
};
|
||
|
||
let root: Root | null = null;
|
||
let host: HTMLElement | null = null;
|
||
|
||
async function mount(ui: React.ReactElement): Promise<void> {
|
||
host = document.createElement('div');
|
||
document.body.appendChild(host);
|
||
await act(async () => {
|
||
root = createRoot(host!);
|
||
root.render(ui);
|
||
});
|
||
}
|
||
|
||
afterEach(async () => {
|
||
if (root) {
|
||
await act(async () => {
|
||
root!.unmount();
|
||
});
|
||
root = null;
|
||
}
|
||
host?.remove();
|
||
host = null;
|
||
if (hasDom) document.body.innerHTML = '';
|
||
});
|
||
|
||
const baseProps = {
|
||
isOpen: true,
|
||
annotations: [annotation],
|
||
blocks: [],
|
||
onSelect: () => {},
|
||
onDelete: () => {},
|
||
selectedId: null,
|
||
};
|
||
|
||
describe('AnnotationPanel consumer props', () => {
|
||
test.skipIf(!hasDom)('default renders the delete affordance (today’s behavior)', async () => {
|
||
await mount(<AnnotationPanel {...baseProps} />);
|
||
expect(document.querySelector('button[title="Delete annotation"]')).not.toBeNull();
|
||
});
|
||
|
||
test.skipIf(!hasDom)('readOnly hides delete and edit affordances', async () => {
|
||
await mount(
|
||
<AnnotationPanel
|
||
{...baseProps}
|
||
readOnly
|
||
onEdit={() => {}}
|
||
/>,
|
||
);
|
||
expect(document.querySelector('button[title="Delete annotation"]')).toBeNull();
|
||
expect(document.querySelector('button[title="Edit annotation"]')).toBeNull();
|
||
});
|
||
|
||
test.skipIf(!hasDom)('readOnly hides direct-edit discard but keeps the host footer slot', async () => {
|
||
// The footer's contents are host-owned and may be pure read affordances
|
||
// (a replies list, a copy link) — view-only panels losing them was the
|
||
// Workspaces regression behind the 0.30.0 change. Built-in mutation
|
||
// affordances stay hidden; the host gates its own footer contents.
|
||
await mount(
|
||
<AnnotationPanel
|
||
{...baseProps}
|
||
readOnly
|
||
directEdits={[{
|
||
id: 'plan',
|
||
added: 1,
|
||
removed: 0,
|
||
diffText: '@@ -1 +1 @@\n-old\n+new',
|
||
onDiscard: () => {},
|
||
}]}
|
||
renderCardFooter={() => <button type="button">Reply</button>}
|
||
/>,
|
||
);
|
||
expect(document.body.textContent).not.toContain('Discard');
|
||
expect(document.querySelector('[data-annotation-card-footer="true"]')).not.toBeNull();
|
||
expect(document.body.textContent).toContain('Reply');
|
||
});
|
||
|
||
test.skipIf(!hasDom)('renderCardFooter renders per-card and does not select the card', async () => {
|
||
const selected: string[] = [];
|
||
let footerClicks = 0;
|
||
await mount(
|
||
<AnnotationPanel
|
||
{...baseProps}
|
||
onSelect={(id) => selected.push(id)}
|
||
renderCardFooter={(a) => (
|
||
<button type="button" data-testid="reply" onClick={() => { footerClicks++; }}>
|
||
reply to {a.id}
|
||
</button>
|
||
)}
|
||
/>,
|
||
);
|
||
|
||
const slot = document.querySelector('[data-annotation-card-footer="true"]');
|
||
expect(slot).not.toBeNull();
|
||
expect(slot!.textContent).toContain('reply to a1');
|
||
|
||
const replyBtn = document.querySelector('[data-testid="reply"]') as HTMLButtonElement;
|
||
await act(async () => {
|
||
replyBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
});
|
||
expect(footerClicks).toBe(1);
|
||
// The click bubbled only to the slot wrapper, which stops propagation —
|
||
// the card's onSelect must not fire.
|
||
expect(selected).toHaveLength(0);
|
||
});
|
||
|
||
test.skipIf(!hasDom)('no footer prop → no slot rendered', async () => {
|
||
await mount(<AnnotationPanel {...baseProps} />);
|
||
expect(document.querySelector('[data-annotation-card-footer="true"]')).toBeNull();
|
||
});
|
||
|
||
test.skipIf(!hasDom)('embedded presentation keeps the timeline and omits panel chrome', async () => {
|
||
await mount(<AnnotationPanel {...baseProps} presentation="embedded" />);
|
||
const panel = document.querySelector<HTMLElement>('[data-annotation-panel="true"]');
|
||
expect(panel?.className).toContain('h-full min-h-0 w-full flex-1');
|
||
expect(document.body.textContent).not.toContain('Annotations');
|
||
expect(document.querySelector('[data-annotation-id="a1"]')).not.toBeNull();
|
||
expect(document.querySelector('.fixed.inset-0')).toBeNull();
|
||
});
|
||
});
|