@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +17 -11
- package/src/App.autoclose.test.tsx +133 -0
- package/src/App.diff.test.tsx +27 -20
- package/src/App.export.test.tsx +19 -28
- package/src/App.plan-review.test.tsx +507 -0
- package/src/App.readonly.test.tsx +26 -27
- package/src/App.rendering.test.tsx +119 -0
- package/src/App.review-panel.test.tsx +123 -0
- package/src/App.submit-confirm.test.tsx +216 -0
- package/src/App.test.tsx +83 -64
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +265 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +208 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +55 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +329 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +87 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +107 -0
- package/src/components/ReviewRail.tsx +180 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +54 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +95 -0
- package/src/components/stories.test.tsx +60 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +196 -30
- package/src/config.ts +128 -16
- package/src/editor.test.ts +134 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +21 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +28 -0
- package/src/intent-dispatch.test.ts +308 -0
- package/src/intent-dispatch.ts +295 -0
- package/src/key-bindings.test.ts +142 -0
- package/src/key-bindings.ts +363 -0
- package/src/keymap.test.ts +477 -0
- package/src/keymap.ts +272 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +13 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.share.test.ts +248 -0
- package/src/session-controller.ts +607 -0
- package/src/share.ts +80 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +26 -3
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +260 -0
- package/src/view-plan.ts +365 -0
- package/src/walk.test.ts +116 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- 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
|
|
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,11 +23,33 @@ 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 = {
|
|
29
|
-
|
|
30
|
-
|
|
50
|
+
/* Unpainted so the terminal background shows through; override in [theme]. */
|
|
51
|
+
bg: "transparent",
|
|
52
|
+
panel: "transparent",
|
|
31
53
|
elevated: "#23232f",
|
|
32
54
|
border: "#3a3a4a",
|
|
33
55
|
text: "#e4e6ec",
|
|
@@ -43,4 +65,5 @@ export const DARK: Theme = {
|
|
|
43
65
|
markSuggestionBg: "#20351f",
|
|
44
66
|
insFg: "#62d96b",
|
|
45
67
|
delFg: "#ff6b6b",
|
|
68
|
+
backdrop: "#14141b",
|
|
46
69
|
}
|
package/src/view-diff.test.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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
|
|
50
|
-
|
|
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
|
|
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
|
|
10
|
+
export type DiffRowKind = "file" | "hunk" | "ctx" | "add" | "del";
|
|
11
11
|
|
|
12
12
|
export interface DiffRow {
|
|
13
|
-
|
|
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({
|
|
25
|
+
rows.push({ kind: "file", text: fileLabel(file), file: file.name });
|
|
26
26
|
for (const hunk of file.hunks) {
|
|
27
|
-
rows.push({
|
|
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
|
|
31
|
-
if (
|
|
32
|
-
for (let
|
|
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
|
-
|
|
35
|
-
text: file.additionLines[
|
|
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
|
|
42
|
+
for (let lineOffset = 0; lineOffset < segment.deletions; lineOffset++) {
|
|
43
43
|
rows.push({
|
|
44
|
-
|
|
45
|
-
text: file.deletionLines[
|
|
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
|
|
50
|
+
for (let lineOffset = 0; lineOffset < segment.additions; lineOffset++) {
|
|
51
51
|
rows.push({
|
|
52
|
-
|
|
53
|
-
text: file.additionLines[
|
|
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[],
|
|
72
|
-
const row = rows[
|
|
73
|
-
const prev = rows[
|
|
74
|
-
const next = rows[
|
|
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.
|
|
78
|
-
suffix: next && (next.
|
|
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
|
+
});
|