Skip to content
119 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import * as Y from "yjs";
5
6import { seed } from "./blocks.ts";
7import { anchorThread, applyEdit, commentMarkKey, findTarget, rangeIds, rangeMarkdown, restoreFrom, unanchorThread } from "./edits.ts";
8import { documentMarkdown, outline, topContainers } from "./markdown.ts";
9
10const PAGE = `# Plan
11
12Intro.
13
14## Rollout
15
16Old rollout.
17
18- step one
19
20### Detail
21
22Fine print.
23
24## Risks
25
26None.
27`;
28
29function docFrom(markdown: string) {
30 const doc = new Y.Doc();
31 const fragment = doc.getXmlFragment("document-store");
32 seed(doc, fragment, markdown);
33 return { doc, fragment };
34}
35
36test("a section runs to the next heading of its level or higher, subsections included", () => {
37 const { fragment } = docFrom(PAGE);
38 const range = findTarget(fragment, { kind: "section", heading: "rollout" })!;
39 assert.equal(rangeMarkdown(fragment, range), "## Rollout\n\nOld rollout.\n\n- step one\n\n### Detail\n\nFine print.");
40 assert.equal(findTarget(fragment, { kind: "section", heading: "Nope" }), null);
41});
42
43test("replacing a section changes only that section", () => {
44 const { doc, fragment } = docFrom(PAGE);
45 const ok = applyEdit(doc, fragment, { kind: "section", heading: "Rollout" }, "## Rollout\n\nShip to 10% first, then everyone.");
46 assert.equal(ok, true);
47 assert.equal(documentMarkdown(fragment), "# Plan\n\nIntro.\n\n## Rollout\n\nShip to 10% first, then everyone.\n\n## Risks\n\nNone.\n");
48});
49
50test("blocks targets use the ids agents read from the outline", () => {
51 const { doc, fragment } = docFrom(PAGE);
52 const blocks = outline(fragment);
53 const intro = blocks.find((b) => b.markdown === "Intro.")!;
54 applyEdit(doc, fragment, { kind: "blocks", from_block: intro.id, to_block: intro.id }, "A better intro.");
55 assert.match(documentMarkdown(fragment), /^# Plan\n\nA better intro\.\n\n## Rollout/);
56 // Untouched blocks keep their ids.
57 assert.equal(outline(fragment)[0]!.id, blocks[0]!.id);
58});
59
60test("append adds at the end; document replaces everything", () => {
61 const { doc, fragment } = docFrom("# A");
62 applyEdit(doc, fragment, { kind: "append" }, "Added.");
63 assert.equal(documentMarkdown(fragment), "# A\n\nAdded.\n");
64 applyEdit(doc, fragment, { kind: "document" }, "");
65 // The editor keeps one empty block.
66 assert.equal(topContainers(fragment).length, 1);
67 assert.equal(documentMarkdown(fragment), "");
68});
69
70test("an edit made on the server merges with a person typing at the same time", () => {
71 const { doc, fragment } = docFrom(PAGE);
72 const person = new Y.Doc();
73 Y.applyUpdate(person, Y.encodeStateAsUpdate(doc));
74 // The person types in the intro while the agent rewrites Risks.
75 const theirs = person.getXmlFragment("document-store");
76 const intro = topContainers(theirs)[1]!.get(0) as Y.XmlElement;
77 (intro.get(0) as Y.XmlText).insert(0, "Hello. ");
78 applyEdit(doc, fragment, { kind: "section", heading: "Risks" }, "## Risks\n\nRollback is one click.");
79 Y.applyUpdate(doc, Y.encodeStateAsUpdate(person));
80 Y.applyUpdate(person, Y.encodeStateAsUpdate(doc));
81 const merged = documentMarkdown(fragment);
82 assert.equal(merged, documentMarkdown(theirs));
83 assert.match(merged, /Hello\. Intro\./);
84 assert.match(merged, /Rollback is one click\./);
85});
86
87test("restoring a version brings back its blocks as a new change", () => {
88 const { doc, fragment } = docFrom("# v1\n\nfirst");
89 const v1 = Y.encodeStateAsUpdate(doc);
90 applyEdit(doc, fragment, { kind: "document" }, "# v2\n\nsecond");
91 const old = new Y.Doc();
92 Y.applyUpdate(old, v1);
93 restoreFrom(doc, fragment, old.getXmlFragment("document-store"));
94 assert.equal(documentMarkdown(fragment), "# v1\n\nfirst\n");
95});
96
97test("a comment anchors to the text between two positions, across blocks", () => {
98 const { doc, fragment } = docFrom("alpha beta\n\ngamma delta");
99 const tops = topContainers(fragment);
100 const first = (tops[0]!.get(0) as Y.XmlElement).get(0) as Y.XmlText;
101 const second = (tops[1]!.get(0) as Y.XmlElement).get(0) as Y.XmlText;
102 const anchor = Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(first, 6));
103 const head = Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(second, 5));
104 const quote = anchorThread(doc, fragment, anchor, head, "t1");
105 assert.equal(quote, "beta gamma");
106 const key = commentMarkKey("t1");
107 assert.match(key, /^comment--[A-Za-z0-9+/=]{8}$/);
108 assert.deepEqual(first.toDelta(), [{ insert: "alpha " }, { insert: "beta", attributes: { [key]: { orphan: false, threadId: "t1" } } }]);
109 // Marks don't show in Markdown.
110 assert.equal(documentMarkdown(fragment), "alpha beta\n\ngamma delta\n");
111 unanchorThread(doc, fragment, "t1");
112 assert.deepEqual(first.toDelta(), [{ insert: "alpha beta" }]);
113});
114
115test("range ids list the blocks a suggestion covers", () => {
116 const { fragment } = docFrom(PAGE);
117 const range = findTarget(fragment, { kind: "section", heading: "Risks" })!;
118 assert.equal(rangeIds(fragment, range).length, 2);
119});