Files
openclaw/packages/markdown-core/src/construct-fallbacks.test.ts
Peter Steinberger b5232bc4dd refactor(markdown): add capability-driven fallbacks (#112985)
* refactor(markdown): add capability-driven fallbacks

* fix(markdown): type task-list core rule state

* style(markdown): make list-prefix return explicit

* fix(markdown): honor label-only plain links

* fix(markdown): retain clipped list provenance
2026-07-23 05:47:48 -04:00

296 lines
9.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyConstructFallbacks } from "./construct-fallbacks.js";
import type {
ConstructSupport,
FormatCapabilityProfile,
FormatConstruct,
} from "./format-capabilities.js";
import { markdownToIR, sliceMarkdownIR, type MarkdownIR } from "./ir.js";
const ALL_NATIVE = {
mechanism: "markdown",
constructs: {
bold: "native",
italic: "native",
underline: "native",
strikethrough: "native",
spoiler: "native",
codeInline: "native",
codeBlock: "native",
codeLanguage: "native",
linkLabel: "native",
heading: "native",
bulletList: "native",
orderedList: "native",
taskList: "native",
table: "native",
blockquote: "native",
image: "native",
mention: "native",
},
chunk: { limit: 4_000, unit: "chars" },
} satisfies FormatCapabilityProfile;
function withSupport(
construct: FormatConstruct,
support: ConstructSupport,
): FormatCapabilityProfile {
return {
...ALL_NATIVE,
constructs: { ...ALL_NATIVE.constructs, [construct]: support },
};
}
function markdownToTaskIR(markdown: string): MarkdownIR {
return markdownToIR(markdown, { enableTaskLists: true });
}
type FallbackCase = {
name: string;
construct: FormatConstruct;
ir: MarkdownIR;
expected: Record<ConstructSupport, [text: string, styles: string[], links: number]>;
};
const TASK_LIST_IR = markdownToTaskIR("- [x] done");
const CASES: FallbackCase[] = [
{
name: "heading",
construct: "heading",
ir: { text: "Title", styles: [{ start: 0, end: 5, style: "heading_2" }], links: [] },
expected: {
native: ["Title", ["heading_2"], 0],
fallback: ["Title", ["bold"], 0],
strip: ["Title", [], 0],
},
},
{
name: "labeled link",
construct: "linkLabel",
ir: { text: "docs", styles: [], links: [{ start: 0, end: 4, href: "https://example.com" }] },
expected: {
native: ["docs", [], 1],
fallback: ["docs (https://example.com)", [], 0],
strip: ["docs", [], 0],
},
},
{
name: "spoiler",
construct: "spoiler",
ir: { text: "secret", styles: [{ start: 0, end: 6, style: "spoiler" }], links: [] },
expected: {
native: ["secret", ["spoiler"], 0],
fallback: ["secret", [], 0],
strip: ["secret", [], 0],
},
},
{
name: "task list",
construct: "taskList",
ir: TASK_LIST_IR,
expected: {
native: ["• [x] done", [], 0],
fallback: ["[x] done", [], 0],
strip: ["• done", [], 0],
},
},
{
name: "code language",
construct: "codeLanguage",
ir: {
text: "const x = 1;",
styles: [{ start: 0, end: 12, style: "code_block", language: "ts" }],
links: [],
},
expected: {
native: ["const x = 1;", ["code_block:ts"], 0],
fallback: ["const x = 1;", ["code_block"], 0],
strip: ["const x = 1;", ["code_block"], 0],
},
},
{
name: "underline",
construct: "underline",
ir: { text: "under", styles: [{ start: 0, end: 5, style: "underline" }], links: [] },
expected: {
native: ["under", ["underline"], 0],
fallback: ["under", [], 0],
strip: ["under", [], 0],
},
},
];
describe("applyConstructFallbacks", () => {
for (const testCase of CASES) {
for (const support of ["native", "fallback", "strip"] as const) {
it(`${testCase.name}: ${support}`, () => {
const actual = applyConstructFallbacks(
testCase.ir,
withSupport(testCase.construct, support),
);
expect(
[
actual.text,
actual.styles.map((span) =>
span.language ? `${span.style}:${span.language}` : span.style,
),
actual.links.length,
],
`${testCase.name}: ${support}`,
).toEqual(testCase.expected[support]);
});
}
}
it("keeps link suffixes outside surrounding styles", () => {
const ir: MarkdownIR = {
text: "see docs now",
styles: [{ start: 0, end: 12, style: "bold" }],
links: [{ start: 4, end: 8, href: "https://example.com" }],
};
expect(applyConstructFallbacks(ir, withSupport("linkLabel", "fallback"))).toEqual({
text: "see docs (https://example.com) now",
styles: [
{ start: 0, end: 8, style: "bold" },
{ start: 30, end: 34, style: "bold" },
],
links: [],
});
});
it("merges heading fallback with authored bold and respects stripped bold", () => {
const ir: MarkdownIR = {
text: "Title",
styles: [
{ start: 0, end: 5, style: "heading_1" },
{ start: 0, end: 5, style: "bold" },
],
links: [],
};
expect(applyConstructFallbacks(ir, withSupport("heading", "fallback")).styles).toEqual([
{ start: 0, end: 5, style: "bold" },
]);
expect(
applyConstructFallbacks(ir, {
...withSupport("heading", "fallback"),
constructs: {
...ALL_NATIVE.constructs,
heading: "fallback",
bold: "strip",
},
}).styles,
).toEqual([]);
});
it("does not infer list constructs from literal code or escaped markers", () => {
const ir = markdownToTaskIR(
"```\n• [x] command\n1. step\n```\n\n- \\[x] literal\n\n-\n [x] indented code",
);
const profile = {
...withSupport("taskList", "fallback"),
constructs: {
...ALL_NATIVE.constructs,
taskList: "fallback",
orderedList: "strip",
},
} satisfies FormatCapabilityProfile;
expect(applyConstructFallbacks(ir, profile).text).toBe(
"• [x] command\n1. step\n\n• [x] literal\n\n• [x] indented code\n",
);
});
it("preserves task-list provenance through IR slicing", () => {
const ir = markdownToTaskIR("intro\n\n- [x] done");
const task = sliceMarkdownIR(ir, ir.text.indexOf("•"), ir.text.length);
expect(applyConstructFallbacks(task, withSupport("taskList", "fallback")).text).toBe(
"[x] done",
);
const markerOnly = sliceMarkdownIR(ir, ir.text.indexOf("[x]"), ir.text.length);
expect(applyConstructFallbacks(markerOnly, withSupport("taskList", "strip")).text).toBe("done");
});
it("recognizes task items nested inside blockquotes", () => {
for (const markdown of ["> - [x] done", "- > - [x] done"]) {
const ir = markdownToTaskIR(markdown);
expect(
applyConstructFallbacks(ir, withSupport("taskList", "fallback")).text,
markdown,
).toContain("[x] done");
expect(applyConstructFallbacks(ir, withSupport("taskList", "strip")).text).not.toContain(
"[x]",
);
}
});
it("recognizes task items nested on the same source line", () => {
const ir = markdownToTaskIR("- - [x] done");
expect(applyConstructFallbacks(ir, withSupport("taskList", "strip")).text).toBe("• \n • done");
});
it("recognizes multiline task-list markers", () => {
const cases = [
{ markdown: "-\n [x] done", expected: "• done" },
{ markdown: "- [x]\n done", expected: "• \ndone" },
{ markdown: "> -\n> [x] done", expected: "• done" },
];
for (const { markdown, expected } of cases) {
const ir = markdownToTaskIR(markdown);
expect(applyConstructFallbacks(ir, withSupport("taskList", "strip")).text, markdown).toBe(
expected,
);
}
});
it("does not consume list separators when stripping an empty task", () => {
const ir = markdownToTaskIR("- [x] \n- next");
expect(applyConstructFallbacks(ir, withSupport("taskList", "strip")).text).toBe("• \n• next");
});
it("preserves task markers that collide with shortcut reference links", () => {
const ir = markdownToTaskIR("- [x] done\n\n[x]: https://example.com");
expect(applyConstructFallbacks(ir, withSupport("taskList", "fallback")).text).toBe("[x] done");
expect(ir.links).toEqual([]);
});
it("does not protect checkbox-like links after an earlier list-item block", () => {
const markdown = "- ---\n\n [x] done\n\n[x]: https://example.com";
expect(markdownToIR(markdown, { enableTaskLists: true })).toEqual(markdownToIR(markdown));
});
it("preserves list provenance through structural clones", () => {
const ir = structuredClone(markdownToTaskIR("- [x] done"));
expect(applyConstructFallbacks(ir, withSupport("taskList", "fallback")).text).toBe("[x] done");
});
it("clips task-list provenance at partial marker slice boundaries", () => {
const ir = markdownToTaskIR("- [x] done");
const prefixOnly = sliceMarkdownIR(ir, 0, 2);
expect(applyConstructFallbacks(prefixOnly, withSupport("taskList", "fallback")).text).toBe("");
const insideListMarker = sliceMarkdownIR(ir, 1, ir.text.length);
const stripListAndTask = {
...withSupport("taskList", "strip"),
constructs: { ...ALL_NATIVE.constructs, taskList: "strip", bulletList: "strip" },
} satisfies FormatCapabilityProfile;
expect(applyConstructFallbacks(insideListMarker, stripListAndTask).text).toBe("done");
for (const start of [3, 4]) {
const sliced = sliceMarkdownIR(ir, start, ir.text.length);
const stripped = applyConstructFallbacks(sliced, withSupport("taskList", "strip"));
expect(stripped.text, `start=${start}`).toBe("done");
}
});
it("clips trailing empty list-marker provenance during finalization", () => {
const cases = [
{ markdown: "-", construct: "bulletList" as const },
{ markdown: "1.", construct: "orderedList" as const },
];
for (const { markdown, construct } of cases) {
const ir = markdownToIR(markdown);
expect(applyConstructFallbacks(ir, withSupport(construct, "strip")).text, markdown).toBe("");
}
});
});