@cueloop/schema 0.1.0-alpha.6 → 0.1.0-alpha.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cutBlock, restoreBlock, restoreLine, sourceChunk } from "./working-copy";
3
+ import { parseBlocks, type Block } from "./markdown";
4
+
5
+ const BASE = `# Plan
6
+
7
+ ## Context
8
+
9
+ A paragraph that
10
+ spans two lines.
11
+
12
+ - first item
13
+ - second item
14
+
15
+ \`\`\`ts
16
+ const x = 1;
17
+ const y = 2;
18
+ \`\`\`
19
+ `;
20
+
21
+ function block(markdown: string, text: string): Block {
22
+ return parseBlocks(markdown).find((block) => block.text.includes(text))!;
23
+ }
24
+
25
+ describe("sourceChunk", () => {
26
+ test("returns the exact source lines a block occupies", () => {
27
+ expect(sourceChunk(BASE, block(BASE, "first item"))).toBe("- first item");
28
+ expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
29
+ "A paragraph that\nspans two lines.",
30
+ );
31
+ });
32
+
33
+ test("code block chunk includes both fence lines", () => {
34
+ expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
35
+ "```ts\nconst x = 1;\nconst y = 2;\n```",
36
+ );
37
+ });
38
+ });
39
+
40
+ describe("cutBlock", () => {
41
+ test("removes the block lines and the blank line before it", () => {
42
+ // Act
43
+ const cut = cutBlock(BASE, block(BASE, "spans two lines"));
44
+
45
+ // Assert
46
+ expect(cut).not.toContain("spans two lines");
47
+ expect(cut).toContain("## Context\n\n- first item");
48
+ });
49
+
50
+ test("cut at the start of the document leaves the leading blank line", () => {
51
+ // Act
52
+ const cut = cutBlock(BASE, block(BASE, "Plan"));
53
+
54
+ // Assert
55
+ expect(cut).not.toContain("# Plan");
56
+ expect(cut.trimStart().startsWith("## Context")).toBe(true);
57
+ });
58
+
59
+ test("cut at the end of the document keeps the rest intact", () => {
60
+ // Act
61
+ const cut = cutBlock(BASE, block(BASE, "const x"));
62
+
63
+ // Assert
64
+ expect(cut).not.toContain("const x");
65
+ expect(cut).toContain("- second item");
66
+ });
67
+ });
68
+
69
+ describe("restoreBlock", () => {
70
+ test("restoring the only cut returns undefined - back to pristine", () => {
71
+ // Arrange
72
+ const listItem = block(BASE, "second item");
73
+ const cut = cutBlock(BASE, listItem);
74
+ // the cut block re-enters before the code fence, the next surviving block
75
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
76
+
77
+ // Act
78
+ const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
79
+
80
+ // Assert
81
+ expect(restored).toBeUndefined();
82
+ });
83
+
84
+ test("restore after another edit returns the merged working copy", () => {
85
+ // Arrange
86
+ const listItem = block(BASE, "second item");
87
+ const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
88
+ const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
89
+
90
+ // Act
91
+ const restored = restoreBlock(
92
+ BASE,
93
+ edited,
94
+ listItem,
95
+ restoreLine(next, edited.split("\n").length),
96
+ );
97
+
98
+ // Assert
99
+ expect(restored).toContain("- second item");
100
+ expect(restored).toContain("first item reworded");
101
+ });
102
+
103
+ test("restore at the end of the document (no next block)", () => {
104
+ // Arrange
105
+ const code = block(BASE, "const x");
106
+ const cut = cutBlock(BASE, code);
107
+
108
+ // Act
109
+ const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
110
+
111
+ // Assert
112
+ expect(restored).toBeUndefined();
113
+ });
114
+
115
+ test("restore a multi-line block round-trips to pristine", () => {
116
+ // Arrange
117
+ const paragraph = block(BASE, "spans two lines");
118
+ const cut = cutBlock(BASE, paragraph);
119
+ const next = parseBlocks(cut).find((block) => block.text === "first item");
120
+
121
+ // Act
122
+ const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
123
+
124
+ // Assert
125
+ expect(restored).toBeUndefined();
126
+ });
127
+
128
+ test("signature rule ignores blank-line layout but not text changes", () => {
129
+ // Arrange
130
+ const listItem = block(BASE, "second item");
131
+ const cut = cutBlock(BASE, listItem);
132
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
133
+ const line = restoreLine(next, cut.split("\n").length);
134
+
135
+ // Assert
136
+ // extra blank lines elsewhere do not block pristine detection
137
+ expect(
138
+ restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line),
139
+ ).toBeUndefined();
140
+ // a real text change keeps the working copy alive
141
+ const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
142
+
143
+ expect(changed).toContain("1st item");
144
+ });
145
+ });
146
+
147
+ describe("restoreLine", () => {
148
+ test("next surviving block's start, or the line count at the end", () => {
149
+ // Arrange
150
+ const listItem = block(BASE, "first item");
151
+
152
+ // Assert
153
+ expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
154
+ expect(restoreLine(undefined, 99)).toBe(99);
155
+ });
156
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Working-copy block surgery: the only module that slices raw source text by
3
+ * Block line ranges. Cut removes a block's lines; restore re-inserts a base
4
+ * block's chunk and reports when the working copy round-trips back to the
5
+ * submitted revision (pristine). Callers never touch lineStart/lineEnd.
6
+ */
7
+
8
+ import { parseBlocks, type Block } from "./markdown";
9
+
10
+ /** Chunk of the base source a block occupies (for restore and display). */
11
+ export function sourceChunk(base: string, block: Block): string {
12
+ const lines = base.split("\n");
13
+
14
+ return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
15
+ }
16
+
17
+ /** Remove a work block's lines from the working source (Cut). */
18
+ export function cutBlock(working: string, block: Block): string {
19
+ const lines = working.split("\n");
20
+ const before = lines.slice(0, block.lineStart);
21
+ const after = lines.slice(block.lineEnd + 1);
22
+
23
+ while (before.length && before[before.length - 1]!.trim() === "") before.pop();
24
+
25
+ return [...before, ...after].join("\n");
26
+ }
27
+
28
+ /** The line a restored block re-enters at: the next surviving block's start. */
29
+ export function restoreLine(next: Block | undefined, workingLineCount: number): number {
30
+ return next ? next.lineStart : workingLineCount;
31
+ }
32
+
33
+ /**
34
+ * Block-structure signature: kind + text of every block, insensitive to
35
+ * blank-line layout between blocks. NUL separates blocks so no block text
36
+ * can fake a boundary.
37
+ */
38
+ function signature(text: string): string {
39
+ return parseBlocks(text)
40
+ .map((block) => block.kind + " " + block.text)
41
+ .join("\0");
42
+ }
43
+
44
+ /**
45
+ * Re-insert a cut base block into the working source before `beforeLine`.
46
+ * Returns undefined when the block structure round-trips to the base content
47
+ * (restore may differ in blank-line placement only) - the working copy is
48
+ * back to pristine and should be dropped.
49
+ */
50
+ export function restoreBlock(
51
+ base: string,
52
+ working: string,
53
+ block: Block,
54
+ beforeLine: number,
55
+ ): string | undefined {
56
+ const lines = working.split("\n");
57
+ const before = lines.slice(0, beforeLine);
58
+ const after = lines.slice(beforeLine);
59
+ const insertedLines = sourceChunk(base, block).split("\n");
60
+
61
+ if (before.length && before[before.length - 1]!.trim() !== "") insertedLines.unshift("");
62
+ if (after.length && after[0]!.trim() !== "") insertedLines.push("");
63
+ const restored = [...before, ...insertedLines, ...after].join("\n");
64
+
65
+ return signature(restored) === signature(base) ? undefined : restored;
66
+ }