Files
backnotprop__plannotator/packages/ui/components/AnnotationPanel.inReplyTo.test.tsx
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:23:28 -07:00

72 lines
3.5 KiB
TypeScript

/**
* Panel threading (DOM-gated): a reply renders indented directly under its
* parent regardless of interleaving timestamps, an orphan reply renders as
* a top-level card, and a list without replies renders no reply wrapper
* (the additive-field guarantee).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { AnnotationType, type Annotation } from '../types';
const hasDom = typeof document !== 'undefined';
const panelModule = hasDom ? await import('./AnnotationPanel') : null;
const AnnotationPanel = panelModule?.AnnotationPanel as NonNullable<typeof panelModule>['AnnotationPanel'];
function ann(id: string, createdA: number, extra: Partial<Annotation> = {}): Annotation {
return { id, blockId: 'blk-a', startOffset: 0, endOffset: 3, type: AnnotationType.COMMENT, text: `text ${id}`, originalText: 'abc', createdA, ...extra };
}
let root: Root | null = null;
let host: HTMLElement | null = null;
async function render(annotations: Annotation[]) {
host = document.createElement('div');
document.body.appendChild(host);
await act(async () => {
root = createRoot(host!);
root.render(
<AnnotationPanel isOpen annotations={annotations} blocks={[]} onSelect={() => {}} onDelete={() => {}} selectedId={null} />,
);
});
return host;
}
afterEach(async () => {
if (root) await act(async () => { root!.unmount(); });
host?.remove();
root = null;
host = null;
});
describe.skipIf(!hasDom)('AnnotationPanel inReplyTo threading', () => {
test('a reply sits right under its parent, indented, even when another comment was created in between', async () => {
const el = await render([ann('p', 1), ann('other', 2), ann('r', 3, { inReplyTo: 'p' })]);
const ids = [...el.querySelectorAll('[data-annotation-id]')].map((n) => n.getAttribute('data-annotation-id'));
expect(ids).toEqual(['p', 'r', 'other']);
const reply = el.querySelector('[data-annotation-reply="true"]');
expect(reply?.querySelector('[data-annotation-id="r"]')).not.toBeNull();
expect(el.querySelectorAll('[data-annotation-reply="true"]').length).toBe(1);
});
test('an orphan reply renders as a top-level card', async () => {
const el = await render([ann('a', 1), ann('r', 2, { inReplyTo: 'gone' })]);
expect(el.querySelectorAll('[data-annotation-reply="true"]').length).toBe(0);
expect([...el.querySelectorAll('[data-annotation-id]')].map((n) => n.getAttribute('data-annotation-id'))).toEqual(['a', 'r']);
});
test('an inReplyTo cycle renders every member as a top-level card, in order, and a reply to one still threads', async () => {
const el = await render([ann('x', 1, { inReplyTo: 'y' }), ann('y', 2, { inReplyTo: 'x' }), ann('r', 3, { inReplyTo: 'x' })]);
expect([...el.querySelectorAll('[data-annotation-id]')].map((n) => n.getAttribute('data-annotation-id'))).toEqual(['x', 'r', 'y']);
const replies = el.querySelectorAll('[data-annotation-reply="true"]');
expect(replies.length).toBe(1);
expect(replies[0].querySelector('[data-annotation-id="r"]')).not.toBeNull();
});
test('without replies there is no reply wrapper and the order is creation order', async () => {
const el = await render([ann('b', 2), ann('a', 1)]);
expect(el.querySelectorAll('[data-annotation-reply="true"]').length).toBe(0);
expect([...el.querySelectorAll('[data-annotation-id]')].map((n) => n.getAttribute('data-annotation-id'))).toEqual(['a', 'b']);
});
});