Must-fix: - REAL BUG: insertInlineFootnote could splice a footnoteReference (inline atom) into a codeBlock or an existing footnoteDefinition, persisting a schema-invalid doc (insert_footnote skips validateDocStructure). Now the search is bounded to the BODY (before the first footnotesList) and the insertNodesAfterAnchor core refuses textblocks that can't hold the atom (codeBlock); when the only match is in such a place the insert returns inserted:false and the write aborts cleanly. Reachable via docmost_transform too. Added codeBlock / definition / fall-through tests. - Fixed the deepEqualJson doc comment in both copies: arrays are order-SENSITIVE (correctness depends on it), only object keys are order-insensitive. - README.ru.md MCP tool count 38 -> 39 (lines 36/47/63), matching README.md/AGENTS. - CHANGELOG [Unreleased] Added entry for insert_footnote + server-side footnote canonicalization on non-editor write paths (#228). Suggestions: - canonicalize step 5/7 now strips footnotesList at ANY depth (both copies), so a schema-valid list nested in a callout/blockquote can't leave duplicate defs. - Exclude the test-only footnote-corpus.ts fixture from the editor-ext build (tsconfig), so it no longer ships in dist/. - Removed the duplicate manual canonicalize cases from the MCP unit test (the shared corpus covers them via full deepEqual); kept idempotence + immutability. - insertInlineFootnote dedup key now keys off the inline array directly (footnoteContentKey({ content: inline })) instead of a throwaway node. Tests / architecture: - New client-wrapper test (#9): overrides a small mutatePage seam to assert the not-found path throws and persists NOTHING, and the success path shapes footnoteId/reused/message/verify and writes the right content. Fixed the misleading comment in footnote-write.test.mjs. - B: cross-copy corpus parity guard test (loads both corpora, asserts deep-equal) so a typo in one copy can't pass both suites green. - A: declined — the full-vs-fragment decision lives at the call site, so a prepareDocForPersist wrapper would be a bare alias for canonicalizeFootnotes; kept the existing per-call-site comments instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
101 lines
4.2 KiB
JavaScript
101 lines
4.2 KiB
JavaScript
// Wrapper tests for DocmostClient.insertFootnote (issue #228, review #11/#9):
|
|
// the page-locked write seam (mutatePage) is overridden so the wrapper's
|
|
// transform + response shaping can be exercised WITHOUT a live Hocuspocus collab
|
|
// socket. We assert the two guarantees that the pure insertInlineFootnote test
|
|
// can NOT prove on its own:
|
|
// - a missing anchor makes the transform throw "anchor text not found" and NO
|
|
// document is persisted (the no-partial-write guarantee), and
|
|
// - a success shapes footnoteId / reused / message / verify and writes a doc
|
|
// carrying the new reference + the derived single list.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { DocmostClient } from "../../build/client.js";
|
|
|
|
const para = (...c) => ({ type: "paragraph", content: c });
|
|
const ref = (id) => ({ type: "footnoteReference", attrs: { id } });
|
|
const def = (id, text) => ({
|
|
type: "footnoteDefinition",
|
|
attrs: { id },
|
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
});
|
|
const list = (...d) => ({ type: "footnotesList", content: d });
|
|
|
|
function findAll(node, type, acc = []) {
|
|
if (!node || typeof node !== "object") return acc;
|
|
if (node.type === type) acc.push(node);
|
|
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
|
|
return acc;
|
|
}
|
|
|
|
// A DocmostClient whose auth + page-locked write are stubbed; `mutatePage`
|
|
// mirrors collaboration.mutatePageContent (run the transform against a clone of
|
|
// the live doc; if it throws, persist NOTHING and rethrow).
|
|
function makeClient(liveDoc) {
|
|
const calls = { writes: [] };
|
|
class TestClient extends DocmostClient {
|
|
async ensureAuthenticated() {}
|
|
async getCollabTokenWithReauth() {
|
|
return "collab-token";
|
|
}
|
|
async mutatePage(pageId, token, apiUrl, transform) {
|
|
calls.pageId = pageId;
|
|
calls.token = token;
|
|
const newDoc = transform(structuredClone(liveDoc));
|
|
calls.writes.push(newDoc);
|
|
return { doc: newDoc, verify: { ok: true, marker: "v" } };
|
|
}
|
|
}
|
|
const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
|
|
return { client, calls };
|
|
}
|
|
|
|
test("insertFootnote: anchor not found -> throws and persists nothing", async () => {
|
|
const { client, calls } = makeClient({
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "nothing to anchor on" })],
|
|
});
|
|
await assert.rejects(
|
|
() => client.insertFootnote("p1", "ZZZ", "a note"),
|
|
/anchor text not found/i,
|
|
);
|
|
assert.equal(calls.writes.length, 0, "no document may be persisted on a missing anchor");
|
|
});
|
|
|
|
test("insertFootnote: success (new) writes a reference + derived list and shapes the response", async () => {
|
|
const { client, calls } = makeClient({
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "The sky is blue today." })],
|
|
});
|
|
const res = await client.insertFootnote("p1", "blue", "Rayleigh scattering.");
|
|
assert.equal(res.success, true);
|
|
assert.equal(res.modified, true);
|
|
assert.equal(res.pageId, "p1");
|
|
assert.equal(res.reused, false);
|
|
assert.equal(typeof res.footnoteId, "string");
|
|
assert.ok(res.footnoteId.length > 0);
|
|
assert.equal(res.message, "Footnote inserted.");
|
|
assert.deepEqual(res.verify, { ok: true, marker: "v" });
|
|
assert.equal(calls.writes.length, 1, "exactly one write persisted");
|
|
assert.equal(findAll(calls.writes[0], "footnoteReference").length, 1);
|
|
assert.equal(findAll(calls.writes[0], "footnotesList").length, 1);
|
|
assert.equal(calls.pageId, "p1");
|
|
});
|
|
|
|
test("insertFootnote: success (reused) reuses the existing definition and reports it", async () => {
|
|
const liveDoc = {
|
|
type: "doc",
|
|
content: [
|
|
para({ type: "text", text: "Alpha and beta." }, ref("a")),
|
|
list(def("a", "shared note")),
|
|
],
|
|
};
|
|
const { client, calls } = makeClient(liveDoc);
|
|
const res = await client.insertFootnote("p1", "beta", "shared note");
|
|
assert.equal(res.reused, true);
|
|
assert.equal(res.footnoteId, "a");
|
|
assert.match(res.message, /reused an existing same-content definition/i);
|
|
// Still exactly one definition (the reused one), two references to it.
|
|
assert.equal(findAll(calls.writes[0], "footnoteDefinition").length, 1);
|
|
assert.equal(findAll(calls.writes[0], "footnoteReference").length, 2);
|
|
});
|