import { describe, it, expect, vi, afterEach } from "vitest"; import { render, fireEvent } from "@testing-library/react"; /** * Structural regression guard for #146 (PR #147). * * Guards ALL THREE editable NodeViews touched by the fix: the two footnote views * (FootnotesListView, FootnoteDefinitionView) AND the code block (CodeBlockView). * * The caret/click-offset fix rests entirely on ONE invariant: in every editable * NodeView the editable `NodeViewContent` (contentDOM) must come FIRST in the * wrapper, with no non-editable (`contenteditable="false"`) element before it. * If a future edit reinserts chrome (separator, heading, marker, back-link, * language menu) ahead of the content, the macOS hit-testing bug returns * silently — and the symptom needs a real browser to see. This test pins the * DOM ORDER (the proxy that IS the fix) in the existing jsdom harness. * * We stub `@tiptap/react` so the views render as plain DOM and we can inspect * the child order our JSX produces — that order is exactly what regresses, and * it does not depend on a live editor. The stubbed `NodeViewContent` carries the * real `data-node-view-content` marker tiptap uses, so the assertion mirrors * production. This test passes on the fixed order and FAILS on the pre-fix order * (chrome-before-content). */ vi.mock("@tiptap/react", () => ({ NodeViewWrapper: ({ children, ...props }: any) => (
). Either way the first element child
// must contain it. (compareDocumentPosition below is NOT redundant here:
// for code-block the content is not the literal first child, so we keep
// the document-order check to prove no chrome precedes the content.)
const firstEl = wrapper.firstElementChild!;
expect(firstEl === content || firstEl.contains(content!)).toBe(true);
// Chrome exists (separator/heading/marker/back-link/menu)...
const nonEditable = wrapper.querySelectorAll('[contenteditable="false"]');
expect(nonEditable.length).toBeGreaterThan(0);
// ...and every non-editable element comes AFTER the contentDOM, so the
// browser's click hit-testing reaches the editable content first (#146).
for (const el of Array.from(nonEditable)) {
const pos = content!.compareDocumentPosition(el);
expect(pos & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
},
);
});
// #168: a footnote referenced more than once shows one lettered backlink per
// occurrence (↩ a b c), each scrolling to its own reference; a single-reference
// footnote keeps the plain ↩.
describe("#168 footnote definition multi-backlinks", () => {
afterEach(() => {
// Reset the shared ref-count mock so other tests see a single reference.
mockRefCount.value = 1;
});
const makeProps = () =>
({
node: { attrs: { id: "fn-1" }, textContent: "" },
editor: {
state: {},
isEditable: true,
commands: { scrollToReference: vi.fn() },
},
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
}) as any;
it("renders one lettered backlink per reference (a, b, c) plus the ↩ arrow", () => {
mockRefCount.value = 3;
const { getByTestId } = render( );
const wrapper = getByTestId("nvw");
const links = wrapper.querySelectorAll('[role="button"]');
expect(Array.from(links).map((l) => l.textContent)).toEqual([
"a",
"b",
"c",
]);
// The ↩ arrow is present (as decorative chrome, not a button).
expect(wrapper.textContent).toContain("↩");
});
it("clicking the n-th backlink scrolls to the n-th occurrence (0-based)", () => {
mockRefCount.value = 3;
const props = makeProps();
const { getByTestId } = render( );
const links = getByTestId("nvw").querySelectorAll('[role="button"]');
fireEvent.click(links[1]); // "b"
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
"fn-1",
1,
);
});
it("a single-reference footnote renders just one ↩ (no letters)", () => {
mockRefCount.value = 1;
const props = makeProps();
const { getByTestId } = render( );
const wrapper = getByTestId("nvw");
const links = wrapper.querySelectorAll('[role="button"]');
expect(links.length).toBe(1);
expect(links[0].textContent).toBe("↩");
fireEvent.click(links[0]);
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
"fn-1",
0,
);
});
});