Files
plainleaf/plugs/editor/outline.test.ts
T

850 lines
16 KiB
TypeScript

import { describe, expect, test } from "vitest";
import {
type CursorContext,
detectContext,
indent,
moveDown,
moveUp,
outdent,
type OutlineResult,
} from "./outline_ops.ts";
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
const CURSOR = "|^|";
function applyOp(
op: (text: string, cursor: number) => OutlineResult,
input: string,
): string {
const pos = input.indexOf(CURSOR);
const clean = input.slice(0, pos) + input.slice(pos + CURSOR.length);
const result = op(clean, pos);
if (result === null || result === "blocked") {
return input; // no-op returns unchanged
}
return (
result.text.slice(0, result.cursor) +
CURSOR +
result.text.slice(result.cursor)
);
}
function detect(marked: string): CursorContext | null {
const pos = marked.indexOf(CURSOR);
const text = marked.slice(0, pos) + marked.slice(pos + CURSOR.length);
const tree = parseMarkdown(text);
return detectContext(tree, pos);
}
// Bullet Lists: Move Up/Down
describe("Bullet list move up/down", () => {
test("swap two flat bullets", () => {
expect(
applyOp(
moveUp,
`- first
- sec|^|ond
`,
),
).toEqual(`- sec|^|ond
- first
`);
expect(
applyOp(
moveDown,
`- fir|^|st
- second
`,
),
).toEqual(`- second
- fir|^|st
`);
});
test("boundary bullet is no-op", () => {
const upInput = `- fir|^|st
- second
`;
expect(applyOp(moveUp, upInput)).toEqual(upInput);
const downInput = `- first
- sec|^|ond
`;
expect(applyOp(moveDown, downInput)).toEqual(downInput);
});
test("item with children moves as unit", () => {
expect(
applyOp(
moveUp,
`- first
- sec|^|ond
- child
`,
),
).toEqual(`- sec|^|ond
- child
- first
`);
expect(
applyOp(
moveDown,
`- fir|^|st
- child
- second
`,
),
).toEqual(`- second
- fir|^|st
- child
`);
});
test("nested child: boundary no-op and swap", () => {
const upBoundary = `- parent
- child |^|one
- child two
`;
expect(applyOp(moveUp, upBoundary)).toEqual(upBoundary);
const downBoundary = `- parent
- child one
- child |^|two
`;
expect(applyOp(moveDown, downBoundary)).toEqual(downBoundary);
expect(
applyOp(
moveUp,
`- parent
- child one
- child |^|two
`,
),
).toEqual(`- parent
- child |^|two
- child one
`);
expect(
applyOp(
moveDown,
`- parent
- child |^|one
- child two
`,
),
).toEqual(`- parent
- child two
- child |^|one
`);
});
test("three items, swap adjacent", () => {
expect(
applyOp(
moveUp,
`- first
- second
- th|^|ird
`,
),
).toEqual(`- first
- th|^|ird
- second
`);
expect(
applyOp(
moveDown,
`- fir|^|st
- second
- third
`,
),
).toEqual(`- second
- fir|^|st
- third
`);
});
});
// Bullet Lists: Indent/Outdent
describe("Bullet list indent/outdent", () => {
test("indent then outdent flat bullet", () => {
const flat = `- first
- sec|^|ond
`;
const nested = `- first
- sec|^|ond
`;
expect(applyOp(indent, flat)).toEqual(nested);
expect(applyOp(outdent, nested)).toEqual(flat);
});
test("boundary no-ops", () => {
const input = `- fir|^|st
- second
`;
expect(applyOp(indent, input)).toEqual(input);
expect(applyOp(outdent, input)).toEqual(input);
});
test("with children", () => {
const flat = `- first
- sec|^|ond
- child
`;
const nested = `- first
- sec|^|ond
- child
`;
expect(applyOp(indent, flat)).toEqual(nested);
expect(applyOp(outdent, nested)).toEqual(flat);
});
test("already nested item indents further", () => {
expect(
applyOp(
indent,
`- first
- sec|^|ond
`,
),
).toEqual(`- first
- sec|^|ond
`);
});
test("deeply nested item outdents one level", () => {
expect(
applyOp(
outdent,
`- first
- second
- th|^|ird
`,
),
).toEqual(`- first
- second
- th|^|ird
`);
});
});
// Headers: Move Up/Down
describe("Heading move up/down", () => {
test("swap two h2 sections", () => {
expect(
applyOp(
moveUp,
`## First
Content one
## Sec|^|ond
Content two
`,
),
).toEqual(`## Sec|^|ond
Content two
## First
Content one
`);
expect(
applyOp(
moveDown,
`## Fir|^|st
Content one
## Second
Content two
`,
),
).toEqual(`## Second
Content two
## Fir|^|st
Content one
`);
});
test("boundary heading is no-op", () => {
const upInput = `## Fir|^|st
Content
## Second
`;
expect(applyOp(moveUp, upInput)).toEqual(upInput);
const downInput = `## First
## Sec|^|ond
`;
expect(applyOp(moveDown, downInput)).toEqual(downInput);
});
test("h2 with sub-headings moves entire section", () => {
expect(
applyOp(
moveUp,
`## First
### Sub one
## Sec|^|ond
### Sub two
`,
),
).toEqual(`## Sec|^|ond
### Sub two
## First
### Sub one
`);
expect(
applyOp(
moveDown,
`## Fir|^|st
### Sub one
## Second
### Sub two
`,
),
).toEqual(`## Second
### Sub two
## Fir|^|st
### Sub one
`);
});
test("h3 swaps only within parent h2 scope", () => {
expect(
applyOp(
moveUp,
`## Parent
### Sub one
### Sub |^|two
`,
),
).toEqual(`## Parent
### Sub |^|two
### Sub one
`);
});
test("headings with no body text", () => {
expect(
applyOp(
moveUp,
`## First
## Sec|^|ond
`,
),
).toEqual(`## Sec|^|ond
## First
`);
});
test("no trailing newline preserves content", () => {
expect(
applyOp(
moveUp,
`## First
Body
## Sec|^|ond
End`,
),
).toEqual(`## Sec|^|ond
End
## First
Body`);
expect(
applyOp(
moveDown,
`## Fir|^|st
Body
## Second
End`,
),
).toEqual(`## Second
End
## Fir|^|st
Body`);
});
test("empty body section swap preserves structure", () => {
expect(
applyOp(
moveDown,
`## |^|A
## B
Body B
`,
),
).toEqual(`## B
Body B
## |^|A
`);
});
test("no trailing newline, first has no body", () => {
expect(
applyOp(
moveDown,
`## |^|A
## B
Body`,
),
).toEqual(`## B
Body
## |^|A`);
});
test("roundtrip: move down then up returns to original", () => {
const original = `## First
Body 1a
Body 1b
## Second
Body 2a
`;
const after = moveDown(original, 4);
if (after === null || after === "blocked") {
throw new Error(`moveDown returned ${after}`);
}
const back = moveUp(after.text, after.cursor);
if (back === null || back === "blocked") {
throw new Error(`moveUp returned ${back}`);
}
expect(back.text).toEqual(original);
});
});
// Headers: Indent/Outdent
describe("Heading indent/outdent", () => {
test("basic", () => {
const h2 = `## He|^|ading
`;
const h3 = `### He|^|ading
`;
expect(applyOp(indent, h2)).toEqual(h3);
expect(applyOp(outdent, h3)).toEqual(h2);
});
test("level limit no-ops", () => {
const h6 = `###### He|^|ading
`;
expect(applyOp(indent, h6)).toEqual(h6);
const h1 = `# He|^|ading
`;
expect(applyOp(outdent, h1)).toEqual(h1);
});
test("with sub-headings", () => {
const shallow = `## Ma|^|in
### Sub
`;
const deep = `### Ma|^|in
#### Sub
`;
expect(applyOp(indent, shallow)).toEqual(deep);
expect(applyOp(outdent, deep)).toEqual(shallow);
});
});
// Paragraphs: Move Up/Down
describe("Paragraph operations", () => {
test("swap two consecutive paragraphs", () => {
expect(
applyOp(
moveUp,
`First para.
Second |^|para.
`,
),
).toEqual(`Second |^|para.
First para.
`);
});
test("boundary no-ops", () => {
const upInput = `First |^|para.
Second para.
`;
expect(applyOp(moveUp, upInput)).toEqual(upInput);
const downInput = `First para.
Second |^|para.
`;
expect(applyOp(moveDown, downInput)).toEqual(downInput);
});
test("moves past an adjacent list block", () => {
expect(
applyOp(
moveDown,
`Some |^|text.
- item one
- item two
`,
),
).toEqual(`- item one
- item two
Some |^|text.
`);
});
});
// Ordered Lists: all operations
describe("Ordered list operations", () => {
test("move up", () => {
expect(
applyOp(
moveUp,
`1. first
2. sec|^|ond
`,
),
).toEqual(`1. sec|^|ond
2. first
`);
});
test("move down", () => {
expect(
applyOp(
moveDown,
`1. fir|^|st
2. second
`,
),
).toEqual(`1. second
2. fir|^|st
`);
});
test("indent", () => {
expect(
applyOp(
indent,
`1. first
2. sec|^|ond
`,
),
).toEqual(`1. first
2. sec|^|ond
`);
});
test("outdent", () => {
expect(
applyOp(
outdent,
`1. first
2. sec|^|ond
`,
),
).toEqual(`1. first
2. sec|^|ond
`);
});
});
// Edge Cases
describe("Edge cases", () => {
test("empty and single-item no-ops", () => {
const empty = `|^|`;
expect(applyOp(moveUp, empty)).toEqual(empty);
expect(applyOp(moveDown, empty)).toEqual(empty);
expect(applyOp(indent, empty)).toEqual(empty);
expect(applyOp(outdent, empty)).toEqual(empty);
const single = `- on|^|ly
`;
expect(applyOp(moveUp, single)).toEqual(single);
expect(applyOp(moveDown, single)).toEqual(single);
});
describe("nested boundary no-ops", () => {
test("move down last item with sub-items", () => {
const input = "- a\n- b|^|\n - sub1\n - sub2";
expect(applyOp(moveDown, input)).toEqual(input);
});
test("move down last nested item", () => {
const input = "- a\n- b\n - sub1\n - sub2|^|";
expect(applyOp(moveDown, input)).toEqual(input);
});
test("move up first item with sub-items", () => {
const input = "- a|^|\n - sub\n- b";
expect(applyOp(moveUp, input)).toEqual(input);
});
test("cursor at end of last nested item blocks move down", () => {
const input = "- a\n- b\n - sub1\n - sub2|^|\n- c";
expect(applyOp(moveDown, input)).toEqual(input);
});
});
test("cursor on leading whitespace of nested item is no-op", () => {
const input = " - a\n - b\n - sub1\n |^| - sub2\n - c";
expect(applyOp(moveDown, input)).toEqual(input);
});
test("cursor at end of nested item moves within nested list", () => {
expect(applyOp(moveDown, "- a\n- b\n - sub1|^|\n - sub2\n- c")).toEqual(
"- a\n- b\n - sub2\n - sub1|^|\n- c",
);
});
});
// Context Detection
describe("Cursor positions in bullet list", () => {
test("on list marker", () => {
const ctx = detect("|^|- one\n- two\n");
expect(ctx?.type).toEqual("listItem");
expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("BulletList");
});
test("on space after marker", () => {
expect(detect("-|^| one\n- two\n")?.type).toEqual("listItem");
});
test("on newline between items", () => {
expect(detect("- one|^|\n- two\n")?.type).toEqual("listItem");
});
test("at last char of item text", () => {
const ctx = detect("- on|^|e\n- two\n");
expect(ctx?.type).toEqual("listItem");
if (ctx?.type === "listItem") {
expect(ctx.itemIndex).toEqual(0);
}
});
test("past end of document", () => {
expect(detect("- one\n- two\n|^|")).toEqual(null);
});
});
describe("Cursor positions on heading", () => {
test("on heading marker", () => {
const ctx = detect("|^|## Heading\nBody\n");
expect(ctx?.type).toEqual("heading");
if (ctx?.type === "heading") {
expect(ctx.level).toEqual(2);
}
});
test("on newline after heading", () => {
expect(detect("## Heading|^|\nBody\n")?.type).toEqual("heading");
});
});
describe("Context detection edge cases", () => {
test("code block returns null", () => {
expect(detect("```\n|^|some code\n```\n")).toEqual(null);
});
test("frontmatter returns null", () => {
expect(detect("---\n|^|title: Test\n---\nContent\n")).toEqual(null);
});
test("between paragraphs resolves to preceding paragraph", () => {
expect(detect("Para one.|^|\n\nPara two.\n")?.type).toEqual("paragraph");
expect(detect("Para one.\n|^|\nPara two.\n")).toEqual(null);
});
test("paragraph inside list item detects list item", () => {
expect(detect("- ite|^|m text\n")?.type).toEqual("listItem");
});
test("heading section boundaries are correct", () => {
const ctx = detect("## |^|H2a\nBody\n### H3\nSub\n## H2b\n");
expect(ctx?.type).toEqual("heading");
if (ctx?.type === "heading") {
expect(ctx.level).toEqual(2);
const types = ctx.doc
.children!.slice(ctx.sectionStart, ctx.sectionEnd)
.filter((c) => c.type)
.map((c) => c.type);
expect(types).toEqual([
"ATXHeading2",
"Paragraph",
"ATXHeading3",
"Paragraph",
]);
}
});
test("deeply nested list item detects innermost list", () => {
const ctx = detect("- parent\n - child\n - gra|^|ndchild\n");
expect(ctx?.type).toEqual("listItem");
if (ctx?.type === "listItem") {
expect(ctx.list.type).toEqual("BulletList");
expect(ctx.item.from).toEqual(23);
}
});
test("paragraph after heading detects paragraph", () => {
expect(detect("## Heading\nBody|^| text here.\n")?.type).toEqual(
"paragraph",
);
});
test("single heading with no following content", () => {
const ctx = detect("## On|^|ly heading\n");
expect(ctx?.type).toEqual("heading");
if (ctx?.type === "heading") {
expect(ctx.sectionStart).toEqual(0);
expect(ctx.sectionEnd).toEqual(ctx.doc.children!.length);
}
});
});
describe("List marker variants", () => {
test("ordered list", () => {
const ctx = detect("|^|1. first\n2. second\n");
expect(ctx?.type).toEqual("listItem");
expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("OrderedList");
});
test("* bullet", () => {
const ctx = detect("|^|* item one\n* item two\n");
expect(ctx?.type).toEqual("listItem");
expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("BulletList");
});
test("blockquote list", () => {
expect(detect("> - |^|item one\n> - item two\n")?.type).toEqual("listItem");
});
});
// Table Rows: Move Up/Down
describe("Table row move up/down", () => {
test("swap two data rows", () => {
expect(
applyOp(
moveUp,
`| A | B |
| --- | --- |
| 1 | 2 |
| 3|^| | 4 |
`,
),
).toEqual(`| A | B |
| --- | --- |
| 3|^| | 4 |
| 1 | 2 |
`);
expect(
applyOp(
moveDown,
`| A | B |
| --- | --- |
| 1|^| | 2 |
| 3 | 4 |
`,
),
).toEqual(`| A | B |
| --- | --- |
| 3 | 4 |
| 1|^| | 2 |
`);
});
test("first data row can't move up", () => {
const input = `| A | B |
| --- | --- |
| 1|^| | 2 |
| 3 | 4 |
`;
expect(applyOp(moveUp, input)).toEqual(input);
});
test("last data row can't move down", () => {
const input = `| A | B |
| --- | --- |
| 1 | 2 |
| 3|^| | 4 |
`;
expect(applyOp(moveDown, input)).toEqual(input);
});
test("header row is not movable", () => {
const input = `| A|^| | B |
| --- | --- |
| 1 | 2 |
`;
expect(applyOp(moveUp, input)).toEqual(input);
expect(applyOp(moveDown, input)).toEqual(input);
});
test("three rows, swap middle", () => {
expect(
applyOp(
moveUp,
`| H1 | H2 |
| --- | --- |
| a | b |
| c|^| | d |
| e | f |
`,
),
).toEqual(`| H1 | H2 |
| --- | --- |
| c|^| | d |
| a | b |
| e | f |
`);
expect(
applyOp(
moveDown,
`| H1 | H2 |
| --- | --- |
| a | b |
| c|^| | d |
| e | f |
`,
),
).toEqual(`| H1 | H2 |
| --- | --- |
| a | b |
| e | f |
| c|^| | d |
`);
});
test("indent/outdent on table row is no-op", () => {
const input = `| A | B |
| --- | --- |
| 1|^| | 2 |
`;
expect(applyOp(indent, input)).toEqual(input);
expect(applyOp(outdent, input)).toEqual(input);
});
});
// Table Rows: Context Detection
describe("Table row context detection", () => {
test("cursor in data row detects tableRow", () => {
const ctx = detect(`| A | B |
| --- | --- |
| 1|^| | 2 |
`);
expect(ctx?.type).toEqual("tableRow");
});
test("cursor in header row detects tableRow with isHeader", () => {
const ctx = detect(`| A|^| | B |
| --- | --- |
| 1 | 2 |
`);
expect(ctx?.type).toEqual("tableRow");
if (ctx?.type === "tableRow") {
expect(ctx.isHeader).toEqual(true);
}
});
test("cursor on delimiter row returns null", () => {
expect(
detect(`| A | B |
| --|^|- | --- |
| 1 | 2 |
`),
).toEqual(null);
});
});