Files
gitmost/packages/editor-ext/src/lib/footnote/footnote-paste.test.ts
claude code agent 227 a0cc625dfe refactor(footnotes): address PR #169 review
- footnote-sync: remove the now-dead `refReids` (CollisionPlan field, local,
  return, the 6a consumer loop) — references are never re-id'd under reuse, so it
  was dead structure on the hot reconciliation path. Rewrite the stale comments
  (plugin header, step 0, refOccurrences field) that still described the old
  "duplicates re-id'd so both survive" model to the reuse model.
- Shared footnote lexer: new packages/mcp/src/lib/footnote-lex.ts
  (lexFootnoteLines + forEachFootnoteReference). extractFootnotes (collaboration)
  and analyzeFootnotes now consume the SAME fence-aware lexer, so "the analyzer
  sees exactly what the importer keeps/strips" is structural, not comment-kept.
  Removed the duplicated DEF_RE/fence machine from both consumers.
- Tests: new mock test for the footnoteWarnings plumbing on createPage (problems
  -> field present; clean -> omitted); new paste-reuse case for TWO colliding
  pasted definitions (reservation -> distinct ids). Updated the derive-id golden
  test header (no MCP copy / parity test anymore).
- CHANGELOG: [Unreleased] entries for footnote reuse (Changed, supersedes 0.93.0)
  and footnoteWarnings (Added).

editor-ext 129, MCP 301, server roundtrip 2; client+server tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:16:30 +03:00

227 lines
7.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Node as PMNode, Fragment, Slice } from "@tiptap/pm/model";
import { FootnoteReference } from "./footnote-reference";
import { FootnotesList } from "./footnotes-list";
import { FootnoteDefinition } from "./footnote-definition";
import { footnotePastePlugin } from "./footnote-sync";
import {
FOOTNOTE_REFERENCE_NAME,
FOOTNOTE_DEFINITION_NAME,
FOOTNOTES_LIST_NAME,
} from "./footnote-util";
// transformPasted reuse semantics (#166): a pasted reference to an id that
// already exists must KEEP the id (reuse → resolves to the existing footnote);
// only a pasted DEFINITION that collides is re-id'd (it would otherwise clobber
// the existing definition's text), and its paired references follow it.
const extensions = [
Document,
Paragraph,
Text,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
];
/** An editor whose doc already contains footnote "a" (ref + definition). */
function makeEditorWithFootnoteA() {
return new Editor({
extensions,
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "x" },
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "a" } },
],
},
{
type: FOOTNOTES_LIST_NAME,
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id: "a" },
content: [
{ type: "paragraph", content: [{ type: "text", text: "note A" }] },
],
},
],
},
],
},
});
}
/** Run footnotePastePlugin's transformPasted against the editor's current doc. */
function paste(editor: Editor, slice: Slice): Slice {
const plugin = footnotePastePlugin();
return plugin.props!.transformPasted!(slice, editor.view);
}
/** Collect the ids of footnote refs/defs in a slice, in order (single DFS). */
function sliceFootnoteIds(slice: Slice): Array<{ kind: string; id: string }> {
const out: Array<{ kind: string; id: string }> = [];
const walk = (frag: Fragment) => {
frag.forEach((node: PMNode) => {
if (node.type.name === FOOTNOTE_REFERENCE_NAME)
out.push({ kind: "ref", id: node.attrs.id });
if (node.type.name === FOOTNOTE_DEFINITION_NAME)
out.push({ kind: "def", id: node.attrs.id });
walk(node.content);
});
};
walk(slice.content);
return out;
}
describe("footnotePastePlugin — reuse-aware id remap", () => {
it("keeps a pasted lone reference to an existing id (reuse, no remap)", () => {
const editor = makeEditorWithFootnoteA();
const { schema } = editor;
// Paste: a paragraph containing only a reference to the existing id "a".
const slice = new Slice(
Fragment.from(
schema.nodes.paragraph.create(null, [
schema.text("see "),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
]),
),
0,
0,
);
const out = paste(editor, slice);
// The reference keeps id "a" so it reuses the existing footnote.
expect(sliceFootnoteIds(out)).toEqual([{ kind: "ref", id: "a" }]);
editor.destroy();
});
it("re-ids a pasted DEFINITION (and its paired reference) that collides", () => {
const editor = makeEditorWithFootnoteA();
const { schema } = editor;
// Paste: a reference AND a definition both carrying the existing id "a". The
// definition would clobber the existing one, so both are remapped together.
const slice = new Slice(
Fragment.fromArray([
schema.nodes.paragraph.create(null, [
schema.text("dup "),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
]),
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
schema.nodes.paragraph.create(null, [schema.text("pasted note")]),
]),
]),
]),
0,
0,
);
const out = paste(editor, slice);
const ids = sliceFootnoteIds(out);
// Both the pasted ref and def were remapped to the SAME fresh id (paired),
// and it is the deterministic derived id (not "a").
const remappedIds = new Set(ids.map((x) => x.id));
expect(remappedIds.size).toBe(1);
expect(remappedIds.has("a")).toBe(false);
expect([...remappedIds][0]).toBe("a__2");
editor.destroy();
});
it("re-ids TWO colliding pasted definitions to DISTINCT ids (reservation works)", () => {
// Existing doc has footnotes "a" and "b". Paste a slice that defines BOTH —
// each must get its own fresh id; the reservation (existing.add(newId)) keeps
// the second from deriving onto the first's new id.
const editor = new Editor({
extensions,
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "a" } },
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "b" } },
],
},
{
type: FOOTNOTES_LIST_NAME,
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id: "a" },
content: [{ type: "paragraph", content: [{ type: "text", text: "A" }] }],
},
{
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id: "b" },
content: [{ type: "paragraph", content: [{ type: "text", text: "B" }] }],
},
],
},
],
},
});
const { schema } = editor;
const slice = new Slice(
Fragment.fromArray([
schema.nodes.paragraph.create(null, [
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }),
]),
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
schema.nodes.paragraph.create(null, [schema.text("pasted A")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
schema.nodes.paragraph.create(null, [schema.text("pasted B")]),
]),
]),
]),
0,
0,
);
const out = paste(editor, slice);
const ids = sliceFootnoteIds(out);
const distinct = new Set(ids.map((x) => x.id));
// Two ids, both remapped off the originals, and distinct from each other.
expect(distinct.size).toBe(2);
expect(distinct.has("a")).toBe(false);
expect(distinct.has("b")).toBe(false);
expect([...distinct].sort()).toEqual(["a__2", "b__2"]);
editor.destroy();
});
it("leaves the slice untouched when no pasted definition collides", () => {
const editor = makeEditorWithFootnoteA();
const { schema } = editor;
// A pasted reference+definition for a BRAND-NEW id "b" — no collision.
const slice = new Slice(
Fragment.fromArray([
schema.nodes.paragraph.create(null, [
schema.text("new "),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }),
]),
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
schema.nodes.paragraph.create(null, [schema.text("note B")]),
]),
]),
]),
0,
0,
);
const out = paste(editor, slice);
expect(sliceFootnoteIds(out)).toEqual([
{ kind: "ref", id: "b" },
{ kind: "def", id: "b" },
]);
editor.destroy();
});
});