@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
Files changed (105) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +133 -0
  3. package/src/App.diff.test.tsx +27 -20
  4. package/src/App.export.test.tsx +19 -28
  5. package/src/App.plan-review.test.tsx +507 -0
  6. package/src/App.readonly.test.tsx +26 -27
  7. package/src/App.rendering.test.tsx +119 -0
  8. package/src/App.review-panel.test.tsx +123 -0
  9. package/src/App.submit-confirm.test.tsx +216 -0
  10. package/src/App.test.tsx +83 -64
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +265 -0
  13. package/src/attribution.test.ts +31 -0
  14. package/src/attribution.ts +32 -0
  15. package/src/clipboard.test.ts +23 -0
  16. package/src/clipboard.ts +26 -0
  17. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  18. package/src/components/AnnotationCard.stories.tsx +75 -0
  19. package/src/components/AnnotationCard.tsx +208 -0
  20. package/src/components/Breadcrumb.stories.tsx +19 -0
  21. package/src/components/Breadcrumb.tsx +38 -0
  22. package/src/components/CodeBlock.stories.tsx +25 -0
  23. package/src/components/CodeBlock.tsx +68 -0
  24. package/src/components/CompletionOverlay.stories.tsx +23 -0
  25. package/src/components/CompletionOverlay.tsx +49 -0
  26. package/src/components/ComposeBar.stories.tsx +18 -0
  27. package/src/components/ComposeBar.tsx +54 -0
  28. package/src/components/ConfirmCard.stories.tsx +37 -0
  29. package/src/components/ConfirmCard.tsx +117 -0
  30. package/src/components/ConfirmDialog.stories.tsx +21 -0
  31. package/src/components/ConfirmDialog.tsx +56 -0
  32. package/src/components/DiffSheet.stories.tsx +44 -0
  33. package/src/components/DiffSheet.tsx +243 -0
  34. package/src/components/InboxList.stories.tsx +18 -0
  35. package/src/components/InboxList.tsx +55 -0
  36. package/src/components/PlanSheet.stories.tsx +81 -0
  37. package/src/components/PlanSheet.tsx +329 -0
  38. package/src/components/PromptDialog.stories.tsx +36 -0
  39. package/src/components/PromptDialog.tsx +62 -0
  40. package/src/components/ReviewPanel.stories.tsx +87 -0
  41. package/src/components/ReviewPanel.tsx +116 -0
  42. package/src/components/ReviewRail.stories.tsx +107 -0
  43. package/src/components/ReviewRail.tsx +180 -0
  44. package/src/components/SettingsDialog.stories.tsx +63 -0
  45. package/src/components/SettingsDialog.tsx +132 -0
  46. package/src/components/SettingsRows.stories.tsx +18 -0
  47. package/src/components/SettingsRows.tsx +92 -0
  48. package/src/components/WalkWizard.stories.tsx +89 -0
  49. package/src/components/WalkWizard.tsx +146 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
  51. package/src/components/primitives/Button.stories.tsx +32 -0
  52. package/src/components/primitives/Button.tsx +44 -0
  53. package/src/components/primitives/Card.stories.tsx +25 -0
  54. package/src/components/primitives/Card.tsx +59 -0
  55. package/src/components/primitives/Dialog.stories.tsx +24 -0
  56. package/src/components/primitives/Dialog.tsx +53 -0
  57. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  58. package/src/components/primitives/StatusBar.tsx +22 -0
  59. package/src/components/primitives/Tabs.stories.tsx +29 -0
  60. package/src/components/primitives/Tabs.tsx +97 -0
  61. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  62. package/src/components/primitives/Toolbar.tsx +14 -0
  63. package/src/components/primitives/frame.test.ts +12 -0
  64. package/src/components/primitives/frame.ts +14 -0
  65. package/src/components/stories-app.tsx +95 -0
  66. package/src/components/stories.test.tsx +56 -0
  67. package/src/components/story-fixtures.ts +90 -0
  68. package/src/components/story.ts +66 -0
  69. package/src/components/syntax-highlight.ts +57 -0
  70. package/src/components/theme-context.stories.tsx +41 -0
  71. package/src/components/theme-context.tsx +30 -0
  72. package/src/components/truncate-text.ts +6 -0
  73. package/src/config.test.ts +196 -30
  74. package/src/config.ts +128 -16
  75. package/src/editor.test.ts +134 -0
  76. package/src/editor.ts +101 -13
  77. package/src/herdr.test.ts +41 -0
  78. package/src/herdr.ts +21 -0
  79. package/src/index.ts +2 -0
  80. package/src/integrations.test.ts +41 -0
  81. package/src/integrations.ts +28 -0
  82. package/src/intent-dispatch.test.ts +308 -0
  83. package/src/intent-dispatch.ts +295 -0
  84. package/src/key-bindings.test.ts +142 -0
  85. package/src/key-bindings.ts +363 -0
  86. package/src/keymap.test.ts +477 -0
  87. package/src/keymap.ts +272 -0
  88. package/src/review-panel.test.ts +111 -0
  89. package/src/review-panel.ts +76 -0
  90. package/src/run.ts +9 -5
  91. package/src/serve.test.ts +13 -6
  92. package/src/serve.ts +7 -7
  93. package/src/session-controller.share.test.ts +248 -0
  94. package/src/session-controller.ts +607 -0
  95. package/src/share.ts +80 -0
  96. package/src/test-support.ts +87 -0
  97. package/src/theme.ts +23 -1
  98. package/src/view-diff.test.ts +19 -8
  99. package/src/view-diff.ts +22 -22
  100. package/src/view-plan.test.ts +260 -0
  101. package/src/view-plan.ts +365 -0
  102. package/src/walk.test.ts +116 -0
  103. package/src/walk.ts +65 -0
  104. package/src/view.test.ts +0 -157
  105. package/src/view.ts +0 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared driving helpers for the virtual-terminal App suites: key presses
3
+ * settle through a macrotask yield (input parser + React scheduler), one
4
+ * render pass, and the renderer's visual-idle wait - no fixed-duration
5
+ * sleeps. Waits on daemon round-trips go through waitForFrame/waitFor with
6
+ * generous pass budgets.
7
+ */
8
+
9
+ import type { TestRendererSetup } from "@opentui/core/testing";
10
+
11
+ /** Pass budget for waits that include daemon or subprocess round-trips. */
12
+ export const WAIT_PASSES = { maxPasses: 400 };
13
+
14
+ /**
15
+ * One macrotask yield lets the input parser and the React scheduler run,
16
+ * then a render pass commits the result before the visual-idle wait.
17
+ */
18
+ export async function settle(setup: TestRendererSetup): Promise<void> {
19
+ await new Promise((resolve) => setTimeout(resolve, 0));
20
+ await setup.renderOnce();
21
+ await setup.waitForVisualIdle();
22
+ }
23
+
24
+ /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
25
+ export async function press(setup: TestRendererSetup, key: string): Promise<void> {
26
+ if (key === "enter") setup.mockInput.pressKey("RETURN");
27
+ else if (key === "escape") setup.mockInput.pressKey("ESCAPE");
28
+ else if (key === "backspace") setup.mockInput.pressKey("BACKSPACE");
29
+ else if (key === "left") setup.mockInput.pressKey("ARROW_LEFT");
30
+ else if (key === "right") setup.mockInput.pressKey("ARROW_RIGHT");
31
+ else return typeText(setup, key);
32
+ await settle(setup);
33
+ }
34
+
35
+ export async function typeText(setup: TestRendererSetup, text: string): Promise<void> {
36
+ await setup.mockInput.typeText(text);
37
+ await settle(setup);
38
+ }
39
+
40
+ const WAIT_DEADLINE_MS = 30_000;
41
+
42
+ /**
43
+ * waitForFrame gives up as soon as the renderer is idle, but external timers
44
+ * (the parser's bare-ESC window, daemon IO, spawned editors) settle on the
45
+ * event loop while nothing is scheduled. Between attempts, one real
46
+ * event-loop turn plus a render pass lets those land; the deadline bounds
47
+ * the whole wait.
48
+ */
49
+ async function waitForFramePredicate(
50
+ setup: TestRendererSetup,
51
+ predicate: (frame: string) => boolean,
52
+ ): Promise<string> {
53
+ const deadline = Date.now() + WAIT_DEADLINE_MS;
54
+ for (;;) {
55
+ try {
56
+ return await setup.waitForFrame(predicate, { maxPasses: 50 });
57
+ } catch (error) {
58
+ if (Date.now() > deadline) throw error;
59
+ await new Promise((resolve) => setTimeout(resolve, 1));
60
+ await setup.renderOnce();
61
+ }
62
+ }
63
+ }
64
+
65
+ /** Wait until a state predicate holds (daemon round-trips included). */
66
+ export async function waitForState(setup: TestRendererSetup, predicate: () => boolean): Promise<void> {
67
+ const deadline = Date.now() + WAIT_DEADLINE_MS;
68
+ for (;;) {
69
+ try {
70
+ return await setup.waitFor(predicate, { maxPasses: 50 });
71
+ } catch (error) {
72
+ if (Date.now() > deadline) throw error;
73
+ await new Promise((resolve) => setTimeout(resolve, 1));
74
+ await setup.renderOnce();
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Wait until the frame contains the needle (daemon round-trips included). */
80
+ export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
81
+ return waitForFramePredicate(setup, (frame) => frame.includes(needle));
82
+ }
83
+
84
+ /** Wait until the frame no longer contains the needle. */
85
+ export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
86
+ return waitForFramePredicate(setup, (frame) => !frame.includes(needle));
87
+ }
package/src/theme.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Semantic theme tokens (#22). Values carry over from the design-system
2
+ * Semantic theme tokens. Values carry over from the design-system
3
3
  * prototypes (tokens.css). One built-in dark theme for now; the theme
4
4
  * registry (palette-derived themes, terminal theme, user overrides) layers
5
5
  * on top of these slots without touching consumers.
@@ -23,6 +23,27 @@ export interface Theme {
23
23
  markSuggestionBg: string;
24
24
  insFg: string;
25
25
  delFg: string;
26
+ /** Dimmed layer painted behind centered dialogs. */
27
+ backdrop: string;
28
+ }
29
+
30
+ /**
31
+ * The dimmed variant behind the walk wizard: every reading color drops to
32
+ * the dim token so the file list stays legible context, not competition.
33
+ * Layout tokens (backgrounds, cursor) keep their values.
34
+ */
35
+ export function dimmedTheme(theme: Theme): Theme {
36
+ return {
37
+ ...theme,
38
+ text: theme.textDim,
39
+ textMuted: theme.textDim,
40
+ accent: theme.textDim,
41
+ green: theme.textDim,
42
+ red: theme.textDim,
43
+ blue: theme.textDim,
44
+ insFg: theme.textDim,
45
+ delFg: theme.textDim,
46
+ };
26
47
  }
27
48
 
28
49
  export const DARK: Theme = {
@@ -43,4 +64,5 @@ export const DARK: Theme = {
43
64
  markSuggestionBg: "#20351f",
44
65
  insFg: "#62d96b",
45
66
  delFg: "#ff6b6b",
67
+ backdrop: "#14141b",
46
68
  }
@@ -21,23 +21,29 @@ index 111..222 100644
21
21
 
22
22
  describe("diffRows", () => {
23
23
  test("flattens files, hunks, and signed lines with line numbers", () => {
24
+ // Act
24
25
  const rows = diffRows(PATCH);
25
- expect(rows[0]).toMatchObject({ t: "file", file: "src/store.ts" });
26
- expect(rows[1]!.t).toBe("hunk");
27
- const del = rows.find((r) => r.t === "del")!;
26
+
27
+ // Assert
28
+ expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
29
+ expect(rows[1]!.kind).toBe("hunk");
30
+ const del = rows.find((row) => row.kind === "del")!;
28
31
  expect(del.text).toContain("private items = [];");
29
32
  expect(del.oldLine).toBe(3);
30
- const adds = rows.filter((r) => r.t === "add");
33
+ const adds = rows.filter((row) => row.kind === "add");
31
34
  expect(adds[0]!.text).toContain("new Map()");
32
35
  expect(adds[0]!.newLine).toBe(3);
33
36
  expect(adds[1]!.newLine).toBe(4);
34
37
  // two hunks
35
- expect(rows.filter((r) => r.t === "hunk").length).toBe(2);
38
+ expect(rows.filter((row) => row.kind === "hunk").length).toBe(2);
36
39
  });
37
40
 
38
41
  test("context lines carry both line numbers", () => {
42
+ // Act
39
43
  const rows = diffRows(PATCH);
40
- const ctx = rows.find((r) => r.t === "ctx")!;
44
+
45
+ // Assert
46
+ const ctx = rows.find((row) => row.kind === "ctx")!;
41
47
  expect(ctx.oldLine).toBe(1);
42
48
  expect(ctx.newLine).toBe(1);
43
49
  });
@@ -45,9 +51,14 @@ describe("diffRows", () => {
45
51
 
46
52
  describe("diffRowAnchor", () => {
47
53
  test("quote is the line, neighbors are the context selectors", () => {
54
+ // Arrange
48
55
  const rows = diffRows(PATCH);
49
- const idx = rows.findIndex((r) => r.text.includes("new Map()"));
50
- const anchor = diffRowAnchor(rows, idx);
56
+ const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
57
+
58
+ // Act
59
+ const anchor = diffRowAnchor(rows, rowIndex);
60
+
61
+ // Assert
51
62
  expect(anchor.quote).toContain("new Map()");
52
63
  expect(anchor.prefix.length).toBeGreaterThan(0);
53
64
  expect(anchor.suffix.length).toBeGreaterThan(0);
package/src/view-diff.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Diff artifact projection (#18 slice 3): flatten @pierre/diffs' parsed
2
+ * Diff artifact projection: flatten @pierre/diffs' parsed
3
3
  * patch model into render rows. Line-anchored annotations use the same
4
4
  * quote-primary anchors as plans: quote = the line content, prefix/suffix =
5
5
  * the neighbor lines - so the whole anchor/feedback pipeline is shared.
@@ -7,10 +7,10 @@
7
7
 
8
8
  import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
9
9
 
10
- export type DiffRowType = "file" | "hunk" | "ctx" | "add" | "del";
10
+ export type DiffRowKind = "file" | "hunk" | "ctx" | "add" | "del";
11
11
 
12
12
  export interface DiffRow {
13
- t: DiffRowType;
13
+ kind: DiffRowKind;
14
14
  text: string;
15
15
  file: string;
16
16
  oldLine?: number;
@@ -22,35 +22,35 @@ export function diffRows(patchText: string): DiffRow[] {
22
22
  const patches = parsePatchFiles(patchText);
23
23
  for (const patch of patches) {
24
24
  for (const file of patch.files) {
25
- rows.push({ t: "file", text: fileLabel(file), file: file.name });
25
+ rows.push({ kind: "file", text: fileLabel(file), file: file.name });
26
26
  for (const hunk of file.hunks) {
27
- rows.push({ t: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
27
+ rows.push({ kind: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
28
28
  let oldLine = hunk.deletionStart;
29
29
  let newLine = hunk.additionStart;
30
- for (const seg of hunk.hunkContent) {
31
- if (seg.type === "context") {
32
- for (let i = 0; i < seg.lines; i++) {
30
+ for (const segment of hunk.hunkContent) {
31
+ if (segment.type === "context") {
32
+ for (let lineOffset = 0; lineOffset < segment.lines; lineOffset++) {
33
33
  rows.push({
34
- t: "ctx",
35
- text: file.additionLines[seg.additionLineIndex + i] ?? "",
34
+ kind: "ctx",
35
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
36
36
  file: file.name,
37
37
  oldLine: oldLine++,
38
38
  newLine: newLine++,
39
39
  });
40
40
  }
41
41
  } else {
42
- for (let i = 0; i < seg.deletions; i++) {
42
+ for (let lineOffset = 0; lineOffset < segment.deletions; lineOffset++) {
43
43
  rows.push({
44
- t: "del",
45
- text: file.deletionLines[seg.deletionLineIndex + i] ?? "",
44
+ kind: "del",
45
+ text: file.deletionLines[segment.deletionLineIndex + lineOffset] ?? "",
46
46
  file: file.name,
47
47
  oldLine: oldLine++,
48
48
  });
49
49
  }
50
- for (let i = 0; i < seg.additions; i++) {
50
+ for (let lineOffset = 0; lineOffset < segment.additions; lineOffset++) {
51
51
  rows.push({
52
- t: "add",
53
- text: file.additionLines[seg.additionLineIndex + i] ?? "",
52
+ kind: "add",
53
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
54
54
  file: file.name,
55
55
  newLine: newLine++,
56
56
  });
@@ -68,14 +68,14 @@ function fileLabel(file: FileDiffMetadata): string {
68
68
  }
69
69
 
70
70
  /** Quote-primary anchor for a diff row: neighbors as context selectors. */
71
- export function diffRowAnchor(rows: DiffRow[], idx: number): { quote: string; prefix: string; suffix: string } {
72
- const row = rows[idx]!;
73
- const prev = rows[idx - 1];
74
- const next = rows[idx + 1];
71
+ export function diffRowAnchor(rows: DiffRow[], rowIndex: number): { quote: string; prefix: string; suffix: string } {
72
+ const row = rows[rowIndex]!;
73
+ const prev = rows[rowIndex - 1];
74
+ const next = rows[rowIndex + 1];
75
75
  return {
76
76
  quote: row.text,
77
- prefix: prev && (prev.t === "ctx" || prev.t === "add" || prev.t === "del") ? prev.text.slice(-24) : "",
78
- suffix: next && (next.t === "ctx" || next.t === "add" || next.t === "del") ? next.text.slice(0, 24) : "",
77
+ prefix: prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del") ? prev.text.slice(-24) : "",
78
+ suffix: next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del") ? next.text.slice(0, 24) : "",
79
79
  };
80
80
  }
81
81
 
@@ -0,0 +1,260 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ blockRuns,
4
+ buildDisplay,
5
+ displayText,
6
+ marksByDisplay,
7
+ nextWorkBlock,
8
+ overlayMarks,
9
+ renderedOffsetFor,
10
+ spanKey,
11
+ startSpan,
12
+ workRangeForRendered,
13
+ } from "./view-plan";
14
+ import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
15
+
16
+ const BASE = `# Plan
17
+
18
+ ## Context
19
+
20
+ The daemon persists sessions to disk atomically.
21
+
22
+ - first item
23
+ - second item
24
+ `;
25
+
26
+ describe("buildDisplay reconciliation", () => {
27
+ test("no working copy: everything is same", () => {
28
+ // Act
29
+ const display = buildDisplay(BASE);
30
+
31
+ // Assert
32
+ expect(display.every((block) => block.type === "same")).toBe(true);
33
+ });
34
+
35
+ test("reworded block pairs as mod (similar)", () => {
36
+ // Arrange
37
+ const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
38
+
39
+ // Act
40
+ const display = buildDisplay(BASE, working);
41
+
42
+ // Assert
43
+ const mod = display.find((block) => block.type === "mod")!;
44
+ expect(mod.base!.text).toContain("persists");
45
+ expect(mod.work!.text).toContain("writes");
46
+ });
47
+
48
+ test("unrelated cut + insert do NOT merge into a mod", () => {
49
+ // Arrange
50
+ const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
51
+
52
+ // Act
53
+ const display = buildDisplay(BASE, working);
54
+
55
+ // Assert
56
+ expect(display.some((block) => block.type === "del" && block.base!.text === "second item")).toBe(true);
57
+ expect(display.some((block) => block.type === "add" && block.work!.text.includes("closing note"))).toBe(true);
58
+ expect(display.some((block) => block.type === "mod")).toBe(false);
59
+ });
60
+
61
+ test("ordered list items get running numbers", () => {
62
+ // Act
63
+ const display = buildDisplay("1. one\n2. two\n3. three\n");
64
+
65
+ // Assert
66
+ expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
67
+ });
68
+ });
69
+
70
+ describe("blockRuns + overlayMarks", () => {
71
+ test("mod blocks emit word-diff runs that reconstruct both sides", () => {
72
+ // Arrange
73
+ const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
74
+
75
+ // Act
76
+ const runs = blockRuns(block, true);
77
+
78
+ // Assert
79
+ const oldSide = runs.filter((run) => run.role !== "ins").map((run) => run.text).join("");
80
+ const newSide = runs.filter((run) => run.role !== "del").map((run) => run.text).join("");
81
+ expect(oldSide).toBe("plain old text here");
82
+ expect(newSide).toBe("plain new text here");
83
+ });
84
+
85
+ test("marks bind to working-text offsets and skip del runs", () => {
86
+ // Arrange
87
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
88
+ const runs = blockRuns(block, true);
89
+
90
+ // Act
91
+ const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
92
+
93
+ // Assert
94
+ const markedText = marked.filter((run) => run.role === "mark-comment").map((run) => run.text).join("");
95
+ expect(markedText).toBe("words");
96
+ // del run text untouched
97
+ expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
98
+ });
99
+
100
+ test("marks never override ins emphasis", () => {
101
+ // Arrange
102
+ const block = buildDisplay("keep\n", "keep added\n")[0]!;
103
+
104
+ // Act
105
+ const runs = overlayMarks(blockRuns(block, true), [{ start: 0, end: 10, role: "mark-comment" }]);
106
+
107
+ // Assert
108
+ expect(runs.some((run) => run.role === "ins")).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe("marksByDisplay", () => {
113
+ test("annotations resolve into display coordinates", () => {
114
+ // Arrange
115
+ const working = BASE.replace("first item", "first item edited beyond recognition of it");
116
+ const display = buildDisplay(BASE, working);
117
+ const ann: Annotation = {
118
+ id: "a1",
119
+ kind: "comment",
120
+ anchor: { quote: "atomically", prefix: "to disk ", suffix: "." },
121
+ body: "define atomically",
122
+ createdAt: "",
123
+ };
124
+
125
+ // Act
126
+ const map = marksByDisplay([ann], display);
127
+
128
+ // Assert
129
+ const [displayIndex, marks] = [...map.entries()][0]!;
130
+ expect(displayText(display[displayIndex]!)).toContain("atomically");
131
+ expect(marks[0]!.annotationId).toBe("a1");
132
+ });
133
+
134
+ test("orphaned annotations produce no marks", () => {
135
+ // Arrange
136
+ const display = buildDisplay(BASE);
137
+ const ann: Annotation = {
138
+ id: "a1",
139
+ kind: "comment",
140
+ anchor: { quote: "text that is gone", prefix: "", suffix: "" },
141
+ body: "",
142
+ createdAt: "",
143
+ };
144
+
145
+ // Assert
146
+ expect(marksByDisplay([ann], display).size).toBe(0);
147
+ });
148
+ });
149
+
150
+ describe("rendered/work offset mapping", () => {
151
+ test("plain blocks map 1:1 in both directions", () => {
152
+ // Arrange
153
+ const block = buildDisplay("alpha beta gamma\n")[0]!;
154
+ const runs = blockRuns(block, true);
155
+
156
+ // Assert
157
+ expect(renderedOffsetFor(runs, 6)).toBe(6);
158
+ expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
159
+ });
160
+
161
+ test("inline del runs shift rendered offsets in word-diffed blocks", () => {
162
+ // Arrange
163
+ // rendered: "old new words linger" - "old " is a del run without offsets
164
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
165
+ const runs = blockRuns(block, true);
166
+ const workText = displayText(block);
167
+ const wordsAt = workText.indexOf("words");
168
+
169
+ // Act
170
+ const rendered = renderedOffsetFor(runs, wordsAt)!;
171
+
172
+ // Assert
173
+ const renderedText = runs.map((run) => run.text).join("");
174
+ expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
175
+ // reading a rendered selection over "words" recovers the work range
176
+ expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
177
+ start: wordsAt,
178
+ end: wordsAt + "words".length,
179
+ });
180
+ });
181
+
182
+ test("a rendered selection spanning a del run keeps only positioned text", () => {
183
+ // Arrange
184
+ const block = buildDisplay("old words\n", "new words\n")[0]!;
185
+ const runs = blockRuns(block, true);
186
+ const renderedText = runs.map((run) => run.text).join("");
187
+
188
+ // Act
189
+ // select everything rendered: the work range is the whole work text
190
+ const range = workRangeForRendered(runs, 0, renderedText.length)!;
191
+
192
+ // Assert
193
+ expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
194
+ });
195
+
196
+ test("offsets outside any positioned run map to null", () => {
197
+ // Arrange
198
+ const runs = blockRuns(buildDisplay("short\n")[0]!, true);
199
+
200
+ // Assert
201
+ expect(renderedOffsetFor(runs, 99)).toBeNull();
202
+ expect(workRangeForRendered(runs, 90, 99)).toBeNull();
203
+ });
204
+ });
205
+
206
+ describe("span mode", () => {
207
+ const text = "one two three four";
208
+ test("v selects the first word; l grows; w slides; $ to end", () => {
209
+ // Act
210
+ let span = startSpan(0, text)!;
211
+
212
+ // Assert
213
+ expect(text.slice(span.start, span.end)).toBe("one");
214
+
215
+ // Act
216
+ span = spanKey(span, "l", text);
217
+
218
+ // Assert
219
+ expect(text.slice(span.start, span.end)).toBe("one two");
220
+
221
+ // Act
222
+ span = spanKey(span, "w", text);
223
+
224
+ // Assert
225
+ expect(text.slice(span.start, span.end)).toBe("two three");
226
+
227
+ // Act
228
+ span = spanKey(span, "$", text);
229
+
230
+ // Assert
231
+ expect(text.slice(span.start, span.end)).toBe("two three four");
232
+ });
233
+ });
234
+
235
+ describe("cut / restore round-trip", () => {
236
+ test("cut removes the block lines; restore puts them back", () => {
237
+ // Arrange
238
+ const blocks = parseBlocks(BASE);
239
+ const listItem = blocks.find((candidate) => candidate.text === "second item")!;
240
+
241
+ // Act
242
+ const cut = cutBlock(BASE, listItem);
243
+
244
+ // Assert
245
+ expect(cut).not.toContain("second item");
246
+
247
+ // Arrange
248
+ const display = buildDisplay(BASE, cut);
249
+ const deletedIndex = display.findIndex((block) => block.type === "del");
250
+ const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
251
+
252
+ // Act
253
+ // undefined = the working copy round-tripped back to the submitted revision
254
+ const restored = restoreBlock(BASE, cut, listItem, line);
255
+
256
+ // Assert
257
+ expect(restored).toBeUndefined();
258
+ expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
259
+ });
260
+ });