@cueloop/schema 0.1.0-alpha.20 → 0.1.0-alpha.22

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/schema",
3
- "version": "0.1.0-alpha.20",
3
+ "version": "0.1.0-alpha.22",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,74 +21,110 @@ function blocksOf(markdown: string) {
21
21
 
22
22
  describe("anchor cascade", () => {
23
23
  test("makeAnchor captures quote + context selectors", () => {
24
+ // Arrange
24
25
  const blocks = blocksOf(DOC);
25
26
  const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
27
+
28
+ // Act
26
29
  const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15); // "Review sessions"
30
+
31
+ // Assert
27
32
  expect(anchor.quote).toBe("Review sessions");
28
33
  expect(anchor.prefix).toBe("");
29
34
  expect(anchor.suffix.startsWith(" currently")).toBe(true);
30
35
  });
31
36
 
32
37
  test("resolves at the recorded position when unchanged", () => {
38
+ // Arrange
33
39
  const blocks = blocksOf(DOC);
34
40
  const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
35
41
  const anchor = makeAnchor(blocks, contextBlockIndex, 7, 15); // "sessions"
42
+
43
+ // Act
36
44
  const resolved = resolveAnchor(anchor, blocks)!;
45
+
46
+ // Assert
37
47
  expect(resolved.blockIndex).toBe(contextBlockIndex);
38
48
  expect(resolved.start).toBe(7);
39
49
  expect(resolved.approximate).toBe(false);
40
50
  });
41
51
 
42
52
  test("survives text changes before the quote (quote search)", () => {
53
+ // Arrange
43
54
  const blocks = blocksOf(DOC);
44
55
  const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
45
56
  const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
46
57
  const edited = blocksOf(DOC.replace("Review sessions currently", "PREFIX. Review sessions now"));
58
+
59
+ // Act
47
60
  const resolved = resolveAnchor(anchor, edited)!;
61
+
62
+ // Assert
48
63
  const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
49
64
  expect(resolved.blockIndex).toBe(editedBlockIndex);
50
65
  expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe("Review sessions");
51
66
  });
52
67
 
53
68
  test("prefix/suffix selectors disambiguate repeated quotes", () => {
69
+ // Arrange
54
70
  const blocks = blocksOf(DOC);
55
71
  const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
56
72
  // anchor on the SECOND "Sessions are written" occurrence
57
73
  const text = blocks[storageBlockIndex]!.text;
58
74
  const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
59
75
  const anchor = makeAnchor(blocks, storageBlockIndex, second, second + "Sessions are written".length);
76
+
77
+ // Act
60
78
  const resolved = resolveAnchor(anchor, blocks)!;
79
+
80
+ // Assert
61
81
  expect(resolved.start).toBe(second);
62
82
  });
63
83
 
64
84
  test("position hint breaks ties when context also repeats", () => {
85
+ // Arrange
65
86
  const repeatedWords = "word word word";
66
87
  const blocks = blocksOf(repeatedWords);
67
88
  const anchor = makeAnchor(blocks, 0, 5, 9); // middle "word"
89
+
90
+ // Act
68
91
  const resolved = resolveAnchor(anchor, blocks)!;
92
+
93
+ // Assert
69
94
  expect(resolved.start).toBe(5);
70
95
  });
71
96
 
72
97
  test("trimmed-quote fallback marks the resolution approximate", () => {
98
+ // Arrange
73
99
  const blocks = blocksOf("some text here");
74
100
  const anchor = { quote: " text ", prefix: "some", suffix: "here", blockIndex: 0, start: 4, end: 10 };
75
101
  const edited = blocksOf("some\ntext\nhere");
76
102
  // " text " (with spaces) is gone, "text" survives
103
+
104
+ // Act
77
105
  const resolved = resolveAnchor(anchor, edited);
106
+
107
+ // Assert
78
108
  expect(resolved).not.toBeNull();
79
109
  expect(resolved!.approximate).toBe(true);
80
110
  });
81
111
 
82
112
  test("orphans when the quote is gone entirely", () => {
113
+ // Arrange
83
114
  const blocks = blocksOf(DOC);
84
115
  const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
85
116
  const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
86
117
  const edited = blocksOf(DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."));
118
+
119
+ // Assert
87
120
  expect(resolveAnchor(anchor, edited)).toBeNull();
88
121
  });
89
122
 
90
123
  test("empty quote never binds", () => {
124
+ // Arrange
91
125
  const blocks = blocksOf(DOC);
126
+
127
+ // Assert
92
128
  expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
93
129
  });
94
130
  });
package/src/diff.test.ts CHANGED
@@ -7,9 +7,14 @@ describe("unifiedDiff", () => {
7
7
  });
8
8
 
9
9
  test("produces hunk headers with correct line numbers", () => {
10
+ // Arrange
10
11
  const oldText = ["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"].join("\n");
11
12
  const newText = ["l1", "l2", "l3", "CHANGED", "l5", "l6", "l7", "l8"].join("\n");
13
+
14
+ // Act
12
15
  const diffLines = unifiedDiff(oldText, newText, 1)!;
16
+
17
+ // Assert
13
18
  expect(diffLines[0]!.kind).toBe("hunk");
14
19
  expect(diffLines[0]!.text).toBe("@@ -3,3 +3,3 @@");
15
20
  expect(diffLines.map((line) => line.text)).toEqual([
@@ -22,18 +27,28 @@ describe("unifiedDiff", () => {
22
27
  });
23
28
 
24
29
  test("distant changes split into separate hunks", () => {
30
+ // Arrange
25
31
  const oldLines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
26
32
  const newLines = [...oldLines];
27
33
  newLines[1] = "first change";
28
34
  newLines[27] = "second change";
35
+
36
+ // Act
29
37
  const diffLines = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
38
+
39
+ // Assert
30
40
  expect(diffLines.filter((line) => line.kind === "hunk").length).toBe(2);
31
41
  });
32
42
 
33
43
  test("applies cleanly: reconstructing new text from old + diff", () => {
44
+ // Arrange
34
45
  const oldText = "keep\ndrop\nkeep2\nkeep3";
35
46
  const newText = "keep\nadded\nkeep2\nkeep3\ntail";
47
+
48
+ // Act
36
49
  const diffLines = unifiedDiff(oldText, newText, 50)!;
50
+
51
+ // Assert
37
52
  const rebuilt: string[] = [];
38
53
  for (const line of diffLines) {
39
54
  if (line.kind === "hunk") continue;
@@ -43,7 +58,10 @@ describe("unifiedDiff", () => {
43
58
  });
44
59
 
45
60
  test("unifiedDiffText carries file headers and path", () => {
61
+ // Act
46
62
  const diffText = unifiedDiffText("a", "b", "docs/plan.md")!;
63
+
64
+ // Assert
47
65
  expect(diffText.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
48
66
  });
49
67
  });
@@ -56,7 +74,10 @@ describe("editStats", () => {
56
74
 
57
75
  describe("wordDiff", () => {
58
76
  test("preserves whitespace tokens and reconstructs both sides", () => {
77
+ // Act
59
78
  const ops = wordDiff("the quick brown fox", "the slow brown foxes");
79
+
80
+ // Assert
60
81
  const oldSide = ops.filter((op) => op.kind !== "add").map((op) => op.oldValue).join("");
61
82
  const newSide = ops.filter((op) => op.kind !== "del").map((op) => op.newValue).join("");
62
83
  expect(oldSide).toBe("the quick brown fox");
@@ -66,7 +87,10 @@ describe("wordDiff", () => {
66
87
 
67
88
  describe("lcsDiff", () => {
68
89
  test("custom equality", () => {
90
+ // Act
69
91
  const ops = lcsDiff([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (oldItem, newItem) => oldItem.v === newItem.v);
92
+
93
+ // Assert
70
94
  expect(ops.map((op) => op.kind)).toEqual(["del", "ctx", "add"]);
71
95
  });
72
96
  });
@@ -25,6 +25,7 @@ function makeAnnotation(partial: Partial<Annotation> & { anchor: Annotation["anc
25
25
 
26
26
  describe("renderFeedback", () => {
27
27
  test("plan edits section carries the one unified diff, directive framing first", () => {
28
+ // Act
28
29
  const feedback = renderFeedback({
29
30
  verdictKind: "request_changes",
30
31
  summary: "Tighten the storage section.",
@@ -33,6 +34,8 @@ describe("renderFeedback", () => {
33
34
  annotations: [],
34
35
  planPath: "docs/plan.md",
35
36
  });
37
+
38
+ // Assert
36
39
  expect(feedback).toContain("# Review: request changes");
37
40
  expect(feedback).toContain("Tighten the storage section.");
38
41
  expect(feedback).toContain("## Plan edits");
@@ -43,6 +46,7 @@ describe("renderFeedback", () => {
43
46
  });
44
47
 
45
48
  test("annotations are located by quote and section", () => {
49
+ // Act
46
50
  const feedback = renderFeedback({
47
51
  verdictKind: "comment",
48
52
  summary: "",
@@ -53,6 +57,8 @@ describe("renderFeedback", () => {
53
57
  }),
54
58
  ],
55
59
  });
60
+
61
+ // Assert
56
62
  expect(feedback).toContain("## Annotations (1)");
57
63
  expect(feedback).toContain("### 1. Comment (§ Context)");
58
64
  expect(feedback).toContain("> daemon memory");
@@ -60,6 +66,7 @@ describe("renderFeedback", () => {
60
66
  });
61
67
 
62
68
  test("suggestions render as replace/with pairs", () => {
69
+ // Act
63
70
  const feedback = renderFeedback({
64
71
  verdictKind: "request_changes",
65
72
  summary: "",
@@ -72,39 +79,52 @@ describe("renderFeedback", () => {
72
79
  }),
73
80
  ],
74
81
  });
82
+
83
+ // Assert
75
84
  expect(feedback).toContain("### 1. Suggested change (§ Storage)");
76
85
  expect(feedback).toContain("Replace:\n> one JSON document");
77
86
  expect(feedback).toContain("With:\n> one durable JSON record");
78
87
  });
79
88
 
80
89
  test("orphaned anchors are flagged, never dropped", () => {
90
+ // Act
81
91
  const feedback = renderFeedback({
82
92
  verdictKind: "comment",
83
93
  summary: "",
84
94
  artifactContent: PLAN,
85
95
  annotations: [makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
86
96
  });
97
+
98
+ // Assert
87
99
  expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
88
100
  expect(feedback).toContain("> text that is gone");
89
101
  });
90
102
 
91
103
  test("empty review says so explicitly", () => {
104
+ // Act
92
105
  const feedback = renderFeedback({
93
106
  verdictKind: "approve",
94
107
  summary: "",
95
108
  artifactContent: PLAN,
96
109
  annotations: [],
97
110
  });
111
+
112
+ // Assert
98
113
  expect(feedback).toContain("_No edits or annotations._");
99
114
  });
100
115
 
101
116
  test("agent notes never come back as feedback items", () => {
117
+ // Arrange
102
118
  const note = makeAnnotation({
103
119
  kind: "note",
104
120
  anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
105
121
  body: "The agent's own explanation of the change.",
106
122
  });
123
+
124
+ // Act
107
125
  const noteOnly = renderFeedback({ verdictKind: "approve", summary: "", artifactContent: PLAN, annotations: [note] });
126
+
127
+ // Assert
108
128
  expect(noteOnly).toContain("_No edits or annotations._");
109
129
  expect(noteOnly).not.toContain("The agent's own explanation of the change.");
110
130
  const mixed = renderFeedback({
@@ -118,8 +138,11 @@ describe("renderFeedback", () => {
118
138
  });
119
139
 
120
140
  test("anchors resolve against the working copy when present", () => {
141
+ // Arrange
121
142
  // annotation made on text that only exists in the edited copy
122
143
  const working = PLAN.replace("## Storage", "## Persistence");
144
+
145
+ // Act
123
146
  const feedback = renderFeedback({
124
147
  verdictKind: "request_changes",
125
148
  summary: "",
@@ -129,6 +152,8 @@ describe("renderFeedback", () => {
129
152
  makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
130
153
  ],
131
154
  });
155
+
156
+ // Assert
132
157
  expect(feedback).toContain("(§ Persistence)");
133
158
  });
134
159
  });
package/src/feedback.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * by quoted text.
6
6
  */
7
7
 
8
- import { isAgentNote, type Annotation, type ReviewSession, type VerdictKind } from "./types";
8
+ import { isAddressed, isAgentNote, type Annotation, type ReviewSession, type VerdictKind } from "./types";
9
9
  import { parseBlocks, sectionOf } from "./markdown";
10
10
  import { resolveAnchor } from "./anchor";
11
11
  import { unifiedDiffText } from "./diff";
@@ -20,14 +20,18 @@ export interface FeedbackInput {
20
20
  annotations: Annotation[];
21
21
  /** Path the agent knows the plan by, for direct reference. */
22
22
  planPath?: string;
23
+ /** Session id, so the document can teach the addressed-ids resubmit call. */
24
+ sessionId?: string;
23
25
  }
24
26
 
25
27
  const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
26
28
 
27
29
  export function renderFeedback(input: FeedbackInput): string {
28
30
  const path = input.planPath ?? "plan.md";
29
- // agent notes are the submitter's own context - never echoed back as feedback
30
- const annotations = input.annotations.filter((annotation) => !isAgentNote(annotation));
31
+ // agent notes are the submitter's own context - never echoed back as
32
+ // feedback - and annotations a previous revision already addressed stay out
33
+ // of the next document, so the agent only ever sees the open items
34
+ const annotations = input.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
31
35
  const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
32
36
  const lines: string[] = [];
33
37
  lines.push("# Review: " + input.verdictKind.replace("_", " "));
@@ -76,7 +80,20 @@ export function renderFeedback(input: FeedbackInput): string {
76
80
  lines.push(annotation.body);
77
81
  }
78
82
  lines.push("");
83
+ lines.push(`annotation id: \`${annotation.id}\``);
84
+ lines.push("");
79
85
  });
86
+ if (input.sessionId) {
87
+ lines.push("## Reporting what you addressed");
88
+ lines.push("");
89
+ lines.push("When you resubmit the revised plan, list the annotation ids you acted on -");
90
+ lines.push("they are marked addressed for the reviewer and leave the open list:");
91
+ lines.push("");
92
+ lines.push("```sh");
93
+ lines.push(`cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`);
94
+ lines.push("```");
95
+ lines.push("");
96
+ }
80
97
  }
81
98
 
82
99
  if (!diff && !annotations.length) {
@@ -94,6 +111,7 @@ export function feedbackForSession(session: ReviewSession, verdictKind: VerdictK
94
111
  workingCopy: session.workingCopy,
95
112
  annotations: session.annotations,
96
113
  planPath: session.artifact.meta.planPath,
114
+ sessionId: session.id,
97
115
  });
98
116
  }
99
117
 
package/src/herdr.test.ts CHANGED
@@ -26,7 +26,10 @@ describe("returnPaneFor", () => {
26
26
  });
27
27
 
28
28
  test("env override wins, session meta is the fallback", () => {
29
+ // Arrange
29
30
  const inside = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p9" };
31
+
32
+ // Assert
30
33
  expect(returnPaneFor("w1:p1", inside)).toBe("w1:p1");
31
34
  expect(returnPaneFor("w1:p1", { ...inside, CUELOOP_RETURN_PANE: "w1:p2" })).toBe("w1:p2");
32
35
  });
@@ -29,7 +29,10 @@ const y = 2;
29
29
 
30
30
  describe("parseBlocks", () => {
31
31
  test("parses every block kind with source line ranges", () => {
32
+ // Act
32
33
  const blocks = parseBlocks(SAMPLE);
34
+
35
+ // Assert
33
36
  expect(blocks.map((block) => block.kind)).toEqual([
34
37
  "h1",
35
38
  "h2",
@@ -51,14 +54,20 @@ describe("parseBlocks", () => {
51
54
  });
52
55
 
53
56
  test("multi-line paragraph keeps its text and range", () => {
57
+ // Arrange
54
58
  const blocks = parseBlocks(SAMPLE);
55
59
  const paragraph = blocks.find((block) => block.kind === "p")!;
60
+
61
+ // Assert
56
62
  expect(paragraph.text).toBe("A paragraph that\nspans two lines.");
57
63
  expect(paragraph.lineEnd - paragraph.lineStart).toBe(1);
58
64
  });
59
65
 
60
66
  test("line ranges index into the source", () => {
67
+ // Arrange
61
68
  const lines = SAMPLE.split("\n");
69
+
70
+ // Assert
62
71
  for (const block of parseBlocks(SAMPLE)) {
63
72
  if (block.kind === "code") {
64
73
  expect(lines[block.lineStart]!.startsWith("```")).toBe(true);
@@ -69,6 +78,7 @@ describe("parseBlocks", () => {
69
78
  });
70
79
 
71
80
  test("round-trips through blockToMd", () => {
81
+ // Arrange
72
82
  const blocks = parseBlocks(SAMPLE);
73
83
  let orderedItemCount = 0;
74
84
  const rebuilt = blocks
@@ -77,21 +87,33 @@ describe("parseBlocks", () => {
77
87
  return blockToMd(block, orderedItemCount || 1);
78
88
  })
79
89
  .join("\n\n");
90
+
91
+ // Act
80
92
  // re-parsing the rebuild yields the same kinds and texts
81
93
  const again = parseBlocks(rebuilt);
94
+
95
+ // Assert
82
96
  expect(again.map((block) => [block.kind, block.text])).toEqual(blocks.map((block) => [block.kind, block.text]));
83
97
  });
84
98
 
85
99
  test("unknown constructs degrade to paragraphs, no content lost", () => {
100
+ // Arrange
86
101
  const md = "| a | b |\n|---|---|\n| 1 | 2 |";
102
+
103
+ // Act
87
104
  const blocks = parseBlocks(md);
105
+
106
+ // Assert
88
107
  expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
89
108
  });
90
109
  });
91
110
 
92
111
  describe("sectionOf", () => {
93
112
  test("returns the nearest preceding heading", () => {
113
+ // Arrange
94
114
  const blocks = parseBlocks(SAMPLE);
115
+
116
+ // Assert
95
117
  const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
96
118
  expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
97
119
  const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
package/src/types.test.ts CHANGED
@@ -11,7 +11,10 @@ describe("verdictAllows", () => {
11
11
 
12
12
  describe("newAnnotationId", () => {
13
13
  test("unique by construction within a process, even on one millisecond", () => {
14
+ // Act
14
15
  const ids = new Set(Array.from({ length: 10_000 }, () => newAnnotationId()));
16
+
17
+ // Assert
15
18
  expect(ids.size).toBe(10_000);
16
19
  for (const id of ids) expect(id).toMatch(/^a_[0-9a-z]+_[0-9a-z]+$/);
17
20
  });
package/src/types.ts CHANGED
@@ -72,9 +72,33 @@ export interface Annotation {
72
72
  body: string;
73
73
  /** Set by resolution when the quote can no longer be found. */
74
74
  orphan?: boolean;
75
+ /**
76
+ * SSH key fingerprint of a share collaborator who authored this note. Absent
77
+ * on the planner's own annotations; the sharing gateway stamps it so the
78
+ * planner can tell whose note is whose and never overwrite a collaborator's.
79
+ */
80
+ author?: string;
81
+ /**
82
+ * Set when a revision addressed this annotation: the agent reported the id
83
+ * on resubmit ("agent"), or the quoted text disappeared from the revised
84
+ * plan ("drift"). Addressed annotations leave the default rail view and the
85
+ * next feedback document, but are never deleted.
86
+ */
87
+ resolution?: AnnotationResolution;
75
88
  createdAt: string;
76
89
  }
77
90
 
91
+ export interface AnnotationResolution {
92
+ /** The revision number whose submission addressed this annotation. */
93
+ revision: number;
94
+ source: "agent" | "drift";
95
+ }
96
+
97
+ /** An annotation a revision has addressed; it leaves the default views. */
98
+ export function isAddressed(annotation: Annotation): boolean {
99
+ return annotation.resolution !== undefined;
100
+ }
101
+
78
102
  export type VerdictKind = "comment" | "approve" | "request_changes";
79
103
 
80
104
  export interface Verdict {
@@ -35,19 +35,28 @@ describe("sourceChunk", () => {
35
35
 
36
36
  describe("cutBlock", () => {
37
37
  test("removes the block lines and the blank line before it", () => {
38
+ // Act
38
39
  const cut = cutBlock(BASE, block(BASE, "spans two lines"));
40
+
41
+ // Assert
39
42
  expect(cut).not.toContain("spans two lines");
40
43
  expect(cut).toContain("## Context\n\n- first item");
41
44
  });
42
45
 
43
46
  test("cut at the start of the document leaves the leading blank line", () => {
47
+ // Act
44
48
  const cut = cutBlock(BASE, block(BASE, "Plan"));
49
+
50
+ // Assert
45
51
  expect(cut).not.toContain("# Plan");
46
52
  expect(cut.trimStart().startsWith("## Context")).toBe(true);
47
53
  });
48
54
 
49
55
  test("cut at the end of the document keeps the rest intact", () => {
56
+ // Act
50
57
  const cut = cutBlock(BASE, block(BASE, "const x"));
58
+
59
+ // Assert
51
60
  expect(cut).not.toContain("const x");
52
61
  expect(cut).toContain("- second item");
53
62
  });
@@ -55,43 +64,66 @@ describe("cutBlock", () => {
55
64
 
56
65
  describe("restoreBlock", () => {
57
66
  test("restoring the only cut returns undefined - back to pristine", () => {
67
+ // Arrange
58
68
  const listItem = block(BASE, "second item");
59
69
  const cut = cutBlock(BASE, listItem);
60
70
  // the cut block re-enters before the code fence, the next surviving block
61
71
  const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
72
+
73
+ // Act
62
74
  const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
75
+
76
+ // Assert
63
77
  expect(restored).toBeUndefined();
64
78
  });
65
79
 
66
80
  test("restore after another edit returns the merged working copy", () => {
81
+ // Arrange
67
82
  const listItem = block(BASE, "second item");
68
83
  const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
69
84
  const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
85
+
86
+ // Act
70
87
  const restored = restoreBlock(BASE, edited, listItem, restoreLine(next, edited.split("\n").length));
88
+
89
+ // Assert
71
90
  expect(restored).toContain("- second item");
72
91
  expect(restored).toContain("first item reworded");
73
92
  });
74
93
 
75
94
  test("restore at the end of the document (no next block)", () => {
95
+ // Arrange
76
96
  const code = block(BASE, "const x");
77
97
  const cut = cutBlock(BASE, code);
98
+
99
+ // Act
78
100
  const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
101
+
102
+ // Assert
79
103
  expect(restored).toBeUndefined();
80
104
  });
81
105
 
82
106
  test("restore a multi-line block round-trips to pristine", () => {
107
+ // Arrange
83
108
  const paragraph = block(BASE, "spans two lines");
84
109
  const cut = cutBlock(BASE, paragraph);
85
110
  const next = parseBlocks(cut).find((block) => block.text === "first item");
111
+
112
+ // Act
86
113
  const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
114
+
115
+ // Assert
87
116
  expect(restored).toBeUndefined();
88
117
  });
89
118
 
90
119
  test("signature rule ignores blank-line layout but not text changes", () => {
120
+ // Arrange
91
121
  const listItem = block(BASE, "second item");
92
122
  const cut = cutBlock(BASE, listItem);
93
123
  const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
94
124
  const line = restoreLine(next, cut.split("\n").length);
125
+
126
+ // Assert
95
127
  // extra blank lines elsewhere do not block pristine detection
96
128
  expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line)).toBeUndefined();
97
129
  // a real text change keeps the working copy alive
@@ -102,7 +134,10 @@ describe("restoreBlock", () => {
102
134
 
103
135
  describe("restoreLine", () => {
104
136
  test("next surviving block's start, or the line count at the end", () => {
137
+ // Arrange
105
138
  const listItem = block(BASE, "first item");
139
+
140
+ // Assert
106
141
  expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
107
142
  expect(restoreLine(undefined, 99)).toBe(99);
108
143
  });