Follow-up to the merged #166/#169. Addresses the second review pass (comment 1227): - footnoteWarnings plumbing: extract a single `footnoteWarningsField(markdown)` helper (footnote-analyze) and use it at all three call sites (create_page, update_page, import_page_markdown) so the field is attached identically. - New unit test footnote-warnings-import.test.mjs pins the contract that was uncovered: the field is present on problems / omitted on clean input, and the IMPORT path analyzes the BODY after the docmost:meta / docmost:comments blocks (a footnote-like token inside those JSON blocks must NOT warn; a real body marker must). Tested via the same pure composition the importer uses (footnoteWarningsField(parseDocmostMarkdown(full).body)) — no collab socket needed; a regression that analyzed fullMarkdown or skipped the body split would now go red. - footnote.marked.ts: correct the stale module header — it claimed "only definitions that have a matching reference are emitted", which was never true (orphan defs are emitted; the editor sync plugin reconciles). Now describes first-wins + reuse + sync reconciliation. - derive-id golden test: rename the describe from "(cross-package drift guard)" to "(deterministic-scheme pin)" — there is no second package to drift against. editor-ext 129, MCP 304 (+3), client+server tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { deriveFootnoteId } from "./footnote-util";
|
|
|
|
/**
|
|
* GOLDEN TABLE for `deriveFootnoteId` (and its private alphabetic `suffix`).
|
|
*
|
|
* `deriveFootnoteId` lives ONLY in editor-ext now — it is used by
|
|
* `resolveCollisions` (re-id of a duplicate definition) and `footnotePastePlugin`
|
|
* (re-id of a pasted colliding definition). The MCP/marked import paths no longer
|
|
* derive ids (duplicate definitions there are first-wins-dropped, #166), so there
|
|
* is no cross-package copy and no parity test to keep in sync. This table pins the
|
|
* deterministic scheme so a future change to it is a conscious one.
|
|
*/
|
|
export const DERIVE_GOLDEN: Array<{
|
|
originalId: string;
|
|
occurrence: number;
|
|
taken: string[];
|
|
expected: string;
|
|
why: string;
|
|
}> = [
|
|
// Base candidate `${id}__${occurrence}` when nothing collides.
|
|
{ originalId: "d", occurrence: 2, taken: [], expected: "d__2", why: "plain base, second occurrence" },
|
|
{ originalId: "d", occurrence: 3, taken: [], expected: "d__3", why: "plain base, third occurrence" },
|
|
// The base is taken -> first alphabetic bump is "b" (NOT "a": suffix starts at 'b').
|
|
{ originalId: "d", occurrence: 2, taken: ["d__2"], expected: "d__2b", why: "base taken -> first bump 'b'" },
|
|
// Base + first bump taken -> "c".
|
|
{ originalId: "d", occurrence: 2, taken: ["d__2", "d__2b"], expected: "d__2c", why: "base+b taken -> 'c'" },
|
|
// A non-contiguous taken set still walks deterministically to the first free slot.
|
|
{
|
|
originalId: "d",
|
|
occurrence: 2,
|
|
taken: ["d__2", "d__2b", "d__2c", "d__2d"],
|
|
expected: "d__2e",
|
|
why: "base + b,c,d taken -> 'e'",
|
|
},
|
|
// >25 bump: base + b..z (the 25 single-letter suffixes) all taken -> "bb".
|
|
// suffix(26) === "bb" (base-25 over b..z, carrying to a two-letter suffix).
|
|
{
|
|
originalId: "d",
|
|
occurrence: 2,
|
|
taken: ["d__2", ...singleLetterSuffixes().map((s) => `d__2${s}`)],
|
|
expected: "d__2bb",
|
|
why: ">25 collisions -> two-letter suffix 'bb'",
|
|
},
|
|
];
|
|
|
|
/** The 25 single-letter suffixes the scheme uses: b, c, ..., z (n = 1..25). */
|
|
function singleLetterSuffixes(): string[] {
|
|
// Mirror of the production suffix() for n in 1..25 (all single letters).
|
|
// n=1 -> 'b' ... n=25 -> 'z'. Used only to BUILD the taken-set for the
|
|
// >25 row; the EXPECTED value (d__2bb) is asserted against the real function.
|
|
return Array.from({ length: 25 }, (_, i) => String.fromCharCode(98 + i));
|
|
}
|
|
|
|
describe("deriveFootnoteId golden table (deterministic-scheme pin)", () => {
|
|
for (const row of DERIVE_GOLDEN) {
|
|
it(`derive("${row.originalId}", ${row.occurrence}, {${row.taken.join(",")}}) === "${row.expected}" — ${row.why}`, () => {
|
|
const got = deriveFootnoteId(
|
|
row.originalId,
|
|
row.occurrence,
|
|
new Set(row.taken),
|
|
);
|
|
expect(got).toBe(row.expected);
|
|
});
|
|
}
|
|
|
|
it("the >25 row's taken-set really contains b..z (25 single letters) plus the base", () => {
|
|
// Sanity-pin the construction so a typo in singleLetterSuffixes() cannot make
|
|
// the >25 assertion pass for the wrong reason.
|
|
const letters = singleLetterSuffixes();
|
|
expect(letters).toHaveLength(25);
|
|
expect(letters[0]).toBe("b");
|
|
expect(letters[24]).toBe("z");
|
|
});
|
|
|
|
it("is a PURE function: it never mutates the taken set it is given", () => {
|
|
const taken = new Set(["d__2"]);
|
|
const before = [...taken];
|
|
deriveFootnoteId("d", 2, taken);
|
|
expect([...taken]).toEqual(before);
|
|
});
|
|
|
|
it("is deterministic: same input -> same output across calls", () => {
|
|
const mk = () => new Set(["d__2", "d__2b"]);
|
|
expect(deriveFootnoteId("d", 2, mk())).toBe(deriveFootnoteId("d", 2, mk()));
|
|
});
|
|
});
|