test(review): add the 4 new test-coverage points from PR #185 re-review

The re-review's blocking/structural points (lease leak, dup-id guard test,
body-before-title test, CHANGELOG, pg18, shared jsonb decoder) were already
addressed in commit 24264ef; this adds the 4 genuinely-new coverage requests:

- pt 6: `scrollToReference(id, index?)` exercised against a live editor DOM —
  selects the index-th `sup[data-footnote-ref][data-id]` occurrence, falls back
  to the first for out-of-range, returns false for an empty id (scrollIntoView
  stubbed). (#168)
- pt 7: export `backlinkLabel` and pin the base-26 carry boundary
  (25->z, 26->aa, 27->ab, 51->az, 52->ba). (#168)
- pt 8: integration fail-open — a PRESENT-but-corrupt tool_allowlist (jsonb
  string scalar holding non-array JSON) reads back as null ("no restriction"),
  covering normalizeRow's degrade branch. (#159 #172/#173)
- pt 9: getFootnoteRefCount cache invalidation — adding a `[^a]` reference bumps
  the cached count 2 -> 3. (#168)

Verified: editor-ext footnote 23; client structure 7 + tsc; server int 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-25 12:08:21 +03:00
parent f80276d41a
commit 30c358a2f8
4 changed files with 141 additions and 2 deletions

View File

@@ -7,7 +7,7 @@ import classes from "./footnote.module.css";
* A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z", * A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z",
* 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention. * 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention.
*/ */
function backlinkLabel(index: number): string { export function backlinkLabel(index: number): string {
let out = ""; let out = "";
let x = index; let x = index;
while (x >= 0) { while (x >= 0) {

View File

@@ -75,7 +75,9 @@ vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
})); }));
import FootnotesListView from "./footnotes-list-view"; import FootnotesListView from "./footnotes-list-view";
import FootnoteDefinitionView from "./footnote-definition-view"; import FootnoteDefinitionView, {
backlinkLabel,
} from "./footnote-definition-view";
import CodeBlockView from "../code-block/code-block-view"; import CodeBlockView from "../code-block/code-block-view";
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and // Minimal NodeViewProps stub: definition view only touches node.attrs.id and
@@ -214,3 +216,16 @@ describe("#168 footnote definition multi-backlinks", () => {
); );
}); });
}); });
// #185 re-review pt 7: backlinkLabel is base-26 (a..z, then aa…). The component
// tests only cover a,b,c (index 0-2); pin the >= 26 carry boundary.
describe("backlinkLabel base-26 boundary (#168)", () => {
it("maps 0->a, 25->z, 26->aa, 27->ab, 51->az, 52->ba", () => {
expect(backlinkLabel(0)).toBe("a");
expect(backlinkLabel(25)).toBe("z");
expect(backlinkLabel(26)).toBe("aa");
expect(backlinkLabel(27)).toBe("ab");
expect(backlinkLabel(51)).toBe("az");
expect(backlinkLabel(52)).toBe("ba");
});
});

View File

@@ -91,6 +91,25 @@ describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () =>
const healed = enabled.find((r) => r.id === id); const healed = enabled.find((r) => r.id === id);
expect(healed?.toolAllowlist).toEqual(['alpha', 'beta']); expect(healed?.toolAllowlist).toEqual(['alpha', 'beta']);
}); });
it('FAIL-OPEN: a present-but-corrupt tool_allowlist reads back as null (no restriction)', async () => {
// #185 re-review pt 8: normalizeRow's fail-open branch — the column is
// PRESENT but does not parse into a string[] (here a jsonb string scalar
// holding non-array JSON). The read must degrade to `null` ("no restriction"),
// not crash. (A warn is logged with the server id; not asserted here.)
const id = randomUUID();
await sql`
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
VALUES (
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
to_jsonb(${'{"not":"an array"}'}::text)
)
`.execute(db);
// Sanity: the column is present (a jsonb string scalar), not SQL NULL.
expect(await jsonbTypeof(id)).toBe('string');
// ...yet the read degrades to null (fail-open).
expect((await repo.findById(id, ws))?.toolAllowlist).toBeNull();
});
}); });
/** /**

View File

@@ -162,6 +162,111 @@ describe('getFootnoteRefCount (cached, live editor)', () => {
expect(getFootnoteRefCount(editor.state, 'nope')).toBe(0); expect(getFootnoteRefCount(editor.state, 'nope')).toBe(0);
editor.destroy(); editor.destroy();
}); });
// #185 re-review pt 9: the cached count must update on a doc change (mirror of
// the number-cache invalidation test) — add another `[^a]` reference and the
// count goes 2 -> 3.
it('recomputes the cached ref count when a reference is added', () => {
const editor = makeEditor({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
{ type: 'text', text: ' and ' },
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
],
},
{
type: FOOTNOTES_LIST_NAME,
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id: 'a' },
content: [{ type: 'paragraph' }],
},
],
},
],
});
expect(getFootnoteRefCount(editor.state, 'a')).toBe(2);
// Insert a THIRD reference to `a` at the start of the first paragraph.
const refType = editor.schema.nodes[FOOTNOTE_REFERENCE_NAME];
editor.view.dispatch(
editor.state.tr.insert(1, refType.create({ id: 'a' })),
);
expect(getFootnoteRefCount(editor.state, 'a')).toBe(3);
editor.destroy();
});
});
// #185 re-review pt 6: scrollToReference picks the index-th occurrence among the
// reused references, falls back to the first for an out-of-range index, and is a
// no-op (false) for an empty id. Runs the REAL command against the editor's DOM
// (scrollIntoView is stubbed — jsdom does not implement it).
describe('scrollToReference command (occurrence selection + fallback)', () => {
it('selects the index-th occurrence, falls back to the first, false for empty id', () => {
const scrolled: Element[] = [];
const original = (Element.prototype as any).scrollIntoView;
(Element.prototype as any).scrollIntoView = function () {
scrolled.push(this as Element);
};
try {
const editor = makeEditor({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
{ type: 'text', text: ' x ' },
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
{ type: 'text', text: ' y ' },
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
],
},
{
type: FOOTNOTES_LIST_NAME,
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id: 'a' },
content: [{ type: 'paragraph' }],
},
],
},
],
});
const sups = editor.view.dom.querySelectorAll(
'sup[data-footnote-ref][data-id="a"]',
);
expect(sups.length).toBe(3);
// index 1 -> the SECOND occurrence.
expect(editor.commands.scrollToReference('a', 1)).toBe(true);
expect(scrolled[scrolled.length - 1]).toBe(sups[1]);
// out-of-range index -> falls back to the FIRST occurrence.
expect(editor.commands.scrollToReference('a', 99)).toBe(true);
expect(scrolled[scrolled.length - 1]).toBe(sups[0]);
// default index (0) -> first.
expect(editor.commands.scrollToReference('a')).toBe(true);
expect(scrolled[scrolled.length - 1]).toBe(sups[0]);
// empty id -> false, no scroll.
const before = scrolled.length;
expect(editor.commands.scrollToReference('')).toBe(false);
expect(scrolled.length).toBe(before);
editor.destroy();
} finally {
(Element.prototype as any).scrollIntoView = original;
}
});
}); });
describe('setFootnote command', () => { describe('setFootnote command', () => {