paperclip/ui/src/components/DocumentAnnotationLayer.tes...

303 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DocumentAnnotationLayer } from "./DocumentAnnotationLayer";
const mockBuildAnchorFromContainerSelection = vi.hoisted(() => vi.fn());
const mockGetContainerTextOffset = vi.hoisted(() => vi.fn());
const mockRangesForNormalizedSpan = vi.hoisted(() => vi.fn());
vi.mock("@/lib/document-annotation-selection", () => ({
buildAnchorFromContainerSelection: mockBuildAnchorFromContainerSelection,
getContainerTextOffset: mockGetContainerTextOffset,
rangesForNormalizedSpan: mockRangesForNormalizedSpan,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
await callback();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function makeRect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
left,
top,
right: left + width,
bottom: top + height,
width,
height,
toJSON: () => ({}),
} as DOMRect;
}
function makeRange(rects: DOMRect[], commonAncestorContainer: Node = document.createTextNode("")): Range {
return {
commonAncestorContainer,
getClientRects: () => rects,
} as unknown as Range;
}
describe("DocumentAnnotationLayer", () => {
let container: HTMLDivElement;
let rectSpy: ReturnType<typeof vi.spyOn>;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockRangesForNormalizedSpan.mockReturnValue([makeRange([makeRect(8, 12, 80, 18)])]);
rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(makeRect(0, 0, 400, 300));
});
afterEach(async () => {
if (root) {
await act(() => root?.unmount());
root = null;
}
rectSpy.mockRestore();
container.remove();
vi.clearAllMocks();
});
it("uses borderless token-driven overlay boxes for annotation highlight states", async () => {
const body = document.createElement("div");
body.textContent = "Annotated body text.";
root = createRoot(container);
await act(async () => {
root?.render(
<DocumentAnnotationLayer
containerRef={{ current: body }}
markdown="Annotated body text."
threads={[
{ id: "active", selectedText: "Annotated", status: "open", anchorState: "active" },
{ id: "focused", selectedText: "body", status: "open", anchorState: "active" },
{ id: "stale", selectedText: "text", status: "open", anchorState: "stale" },
{ id: "resolved", selectedText: "body text", status: "resolved", anchorState: "active" },
]}
focusedThreadId="focused"
onThreadFocus={vi.fn()}
pendingAnchor={null}
onPendingAnchorChange={vi.fn()}
onRequestComment={vi.fn()}
hideResolved={false}
/>,
);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
const highlights = Array.from(container.querySelectorAll(".paperclip-doc-annotation-highlight"));
expect(highlights).toHaveLength(4);
for (const highlight of highlights) {
expect(highlight.className).toBe("paperclip-doc-annotation-highlight absolute");
expect(highlight.className).not.toContain("outline");
expect(highlight.className).not.toContain("shadow");
}
expect(highlights.find((highlight) => highlight.getAttribute("data-thread-id") === "focused")
?.getAttribute("data-focused")).toBe("true");
expect(highlights.find((highlight) => highlight.getAttribute("data-thread-id") === "stale")
?.getAttribute("data-anchor-state")).toBe("stale");
expect(highlights.find((highlight) => highlight.getAttribute("data-thread-id") === "resolved")
?.getAttribute("data-status")).toBe("resolved");
});
it("does not render highlights for text clipped by folded document content", async () => {
const body = document.createElement("div");
const clippedContent = document.createElement("div");
clippedContent.className = "fold-curtain__content";
const hiddenText = document.createTextNode("Hidden folded text");
clippedContent.appendChild(hiddenText);
body.appendChild(clippedContent);
mockRangesForNormalizedSpan.mockReturnValue([makeRange([makeRect(8, 60, 80, 18)], hiddenText)]);
rectSpy.mockImplementation(function (this: HTMLElement) {
if (this === clippedContent) return makeRect(0, 0, 400, 40);
return makeRect(0, 0, 400, 120);
});
root = createRoot(container);
await act(async () => {
root?.render(
<DocumentAnnotationLayer
containerRef={{ current: body }}
markdown="Hidden folded text"
threads={[
{ id: "hidden", selectedText: "Hidden folded text", status: "open", anchorState: "active" },
]}
focusedThreadId={null}
onThreadFocus={vi.fn()}
pendingAnchor={null}
onPendingAnchorChange={vi.fn()}
onRequestComment={vi.fn()}
/>,
);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull();
expect(container.querySelector(".paperclip-doc-annotation-hit-target")).toBeNull();
});
it("does not capture annotation comments from editable selections", async () => {
const body = document.createElement("div");
const editable = document.createElement("div");
editable.setAttribute("contenteditable", "true");
const text = document.createTextNode("Editing routine instructions");
editable.appendChild(text);
body.appendChild(editable);
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, "Editing".length);
const getSelectionSpy = vi.spyOn(window, "getSelection").mockReturnValue({
rangeCount: 1,
isCollapsed: false,
getRangeAt: () => range,
} as unknown as Selection);
const onPendingAnchorChange = vi.fn();
root = createRoot(container);
try {
await act(async () => {
root?.render(
<DocumentAnnotationLayer
containerRef={{ current: body }}
markdown="Editing routine instructions"
threads={[]}
focusedThreadId={null}
onThreadFocus={vi.fn()}
pendingAnchor={null}
onPendingAnchorChange={onPendingAnchorChange}
onRequestComment={vi.fn()}
/>,
);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
await act(async () => {
document.dispatchEvent(new Event("selectionchange"));
});
expect(mockGetContainerTextOffset).not.toHaveBeenCalled();
expect(mockBuildAnchorFromContainerSelection).not.toHaveBeenCalled();
expect(onPendingAnchorChange).toHaveBeenCalledWith(null);
expect(container.querySelector('[data-testid="document-annotation-selection-toolbar"]')).toBeNull();
} finally {
getSelectionSpy.mockRestore();
}
});
it("does not capture annotation comments from bare contenteditable selections", async () => {
const body = document.createElement("div");
const editable = document.createElement("div");
editable.setAttribute("contenteditable", "");
const text = document.createTextNode("Editing routine instructions");
editable.appendChild(text);
body.appendChild(editable);
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, "Editing".length);
const getSelectionSpy = vi.spyOn(window, "getSelection").mockReturnValue({
rangeCount: 1,
isCollapsed: false,
getRangeAt: () => range,
} as unknown as Selection);
const onPendingAnchorChange = vi.fn();
root = createRoot(container);
try {
await act(async () => {
root?.render(
<DocumentAnnotationLayer
containerRef={{ current: body }}
markdown="Editing routine instructions"
threads={[]}
focusedThreadId={null}
onThreadFocus={vi.fn()}
pendingAnchor={null}
onPendingAnchorChange={onPendingAnchorChange}
onRequestComment={vi.fn()}
/>,
);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
await act(async () => {
document.dispatchEvent(new Event("selectionchange"));
});
expect(mockGetContainerTextOffset).not.toHaveBeenCalled();
expect(mockBuildAnchorFromContainerSelection).not.toHaveBeenCalled();
expect(onPendingAnchorChange).toHaveBeenCalledWith(null);
expect(container.querySelector('[data-testid="document-annotation-selection-toolbar"]')).toBeNull();
} finally {
getSelectionSpy.mockRestore();
}
});
it("keeps native CSS highlight ranges synchronized while using the overlay for visual paint", async () => {
const originalCss = globalThis.CSS;
const originalHighlight = (globalThis as { Highlight?: unknown }).Highlight;
const setHighlight = vi.fn();
const deleteHighlight = vi.fn();
class MockHighlight {
ranges: Range[];
constructor(...ranges: Range[]) {
this.ranges = ranges;
}
}
(globalThis as { CSS?: unknown }).CSS = {
...(originalCss ?? {}),
highlights: {
set: setHighlight,
delete: deleteHighlight,
},
};
(globalThis as { Highlight?: unknown }).Highlight = MockHighlight;
const body = document.createElement("div");
body.textContent = "Annotated body text.";
root = createRoot(container);
await act(async () => {
root?.render(
<DocumentAnnotationLayer
containerRef={{ current: body }}
markdown="Annotated body text."
threads={[
{ id: "active", selectedText: "Annotated", status: "open", anchorState: "active" },
]}
focusedThreadId={null}
onThreadFocus={vi.fn()}
pendingAnchor={null}
onPendingAnchorChange={vi.fn()}
onRequestComment={vi.fn()}
/>,
);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
expect(container.querySelector(".paperclip-doc-annotation-highlight")).not.toBeNull();
expect(container.querySelector(".paperclip-doc-annotation-hit-target")).not.toBeNull();
const openHighlightCall = setHighlight.mock.calls.find(([name]) => name === "paperclip-doc-annotation-open");
expect(openHighlightCall).toBeTruthy();
expect((openHighlightCall?.[1] as MockHighlight).ranges).toHaveLength(1);
await act(async () => root?.unmount());
root = null;
expect(deleteHighlight).toHaveBeenCalledWith("paperclip-doc-annotation-open");
(globalThis as { CSS?: unknown }).CSS = originalCss;
(globalThis as { Highlight?: unknown }).Highlight = originalHighlight;
});
});