1421 lines
43 KiB
TypeScript
1421 lines
43 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { parseChunkTree } from "@oh-my-pi/pi-natives";
|
|
import { applyChunkEdits, formatChunkedRead, parseChunkReadPath } from "../../src/tools/chunk-tree";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// parseChunkReadPath
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("parseChunkReadPath", () => {
|
|
test("plain file path returns filePath only", () => {
|
|
expect(parseChunkReadPath("file.ts")).toEqual({ filePath: "file.ts" });
|
|
});
|
|
|
|
test("path with chunk selector returns both", () => {
|
|
expect(parseChunkReadPath("file.ts:class_Foo")).toEqual({
|
|
filePath: "file.ts",
|
|
selector: "class_Foo",
|
|
});
|
|
});
|
|
|
|
test("path with raw line selector", () => {
|
|
expect(parseChunkReadPath("file.ts:L42")).toEqual({
|
|
filePath: "file.ts",
|
|
selector: "L42",
|
|
});
|
|
});
|
|
|
|
test("path with chunk checksum suffix normalizes selector", () => {
|
|
expect(parseChunkReadPath("file.ts:class_Foo.fn_bar#ZZPM")).toEqual({
|
|
filePath: "file.ts",
|
|
selector: "class_Foo.fn_bar",
|
|
});
|
|
});
|
|
|
|
test("trailing colon with no selector yields undefined selector", () => {
|
|
const result = parseChunkReadPath("file.ts:");
|
|
expect(result.filePath).toBe("file.ts");
|
|
expect(result.selector).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// applyChunkEdits
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const testSource = `class Worker {
|
|
\tconstructor(name: string) {
|
|
\t\tthis.name = name;
|
|
\t}
|
|
|
|
\trun(): void {
|
|
\t\tconsole.log(this.name);
|
|
\t}
|
|
}
|
|
`;
|
|
|
|
let tmpDir: string | undefined;
|
|
|
|
afterEach(async () => {
|
|
if (tmpDir) {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
tmpDir = undefined;
|
|
}
|
|
});
|
|
|
|
function edit(operations: Parameters<typeof applyChunkEdits>[0]["operations"], source = testSource) {
|
|
return applyEdit({
|
|
source,
|
|
language: "typescript",
|
|
operations,
|
|
});
|
|
}
|
|
|
|
function applyEdit(params: {
|
|
source: string;
|
|
language: string;
|
|
operations: Parameters<typeof applyChunkEdits>[0]["operations"];
|
|
filePath?: string;
|
|
}) {
|
|
return applyChunkEdits({
|
|
source: params.source,
|
|
language: params.language,
|
|
cwd: "/tmp",
|
|
filePath: params.filePath ?? `/tmp/source.${params.language}`,
|
|
operations: params.operations,
|
|
});
|
|
}
|
|
|
|
function getChecksum(source: string, chunkPath: string, language = "typescript"): string {
|
|
const chunk = parseChunkTree(source, language).chunks.find(candidate => candidate.path === chunkPath);
|
|
if (!chunk) {
|
|
throw new Error(`Chunk not found in test fixture: ${chunkPath}`);
|
|
}
|
|
return chunk.checksum;
|
|
}
|
|
|
|
describe("applyChunkEdits", () => {
|
|
test("replace accepts a copied chunk header with checksum suffix", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
const result = edit([
|
|
{
|
|
op: "replace",
|
|
...ac,
|
|
content: `\tstop(): void {\n\t\tconsole.log("stopped");\n\t}`,
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).toContain("stop()");
|
|
expect(result.diffSourceAfter).not.toContain("run()");
|
|
const newChecksum = getChecksum(result.diffSourceAfter, "class_Worker.fn_stop");
|
|
expect(newChecksum).not.toBe(ac.crc);
|
|
});
|
|
|
|
test("replace with wrong checksum throws with mismatch", () => {
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "replace",
|
|
sel: "class_Worker.fn_run",
|
|
crc: "ZZZZ",
|
|
content: "replacement",
|
|
},
|
|
]),
|
|
).toThrow(/mismatch/i);
|
|
});
|
|
|
|
test("append_child on branch inserts after existing members", () => {
|
|
const result = edit([
|
|
{
|
|
op: "append_child",
|
|
sel: "class_Worker",
|
|
content: `\tstatus(): string {\n\t\treturn "active";\n\t}`,
|
|
},
|
|
]);
|
|
|
|
const after = result.diffSourceAfter;
|
|
expect(after).toContain("status()");
|
|
const runPos = after.indexOf("run()");
|
|
const statusPos = after.indexOf("status()");
|
|
expect(statusPos).toBeGreaterThan(runPos);
|
|
expect(after).toContain("class Worker");
|
|
});
|
|
|
|
test("append_child on an empty container inserts inside the container", () => {
|
|
const result = edit(
|
|
[
|
|
{
|
|
op: "append_child",
|
|
sel: "class_Empty",
|
|
content: "method(): void {}\n",
|
|
},
|
|
],
|
|
"class Empty {}\n",
|
|
);
|
|
|
|
expect(result.diffSourceAfter).toContain("class Empty {");
|
|
expect(result.diffSourceAfter).toContain("method(): void {}");
|
|
expect(result.diffSourceAfter).toMatch(/class Empty \{[\s\S]*method\(\): void \{\}[\s\S]*\}/);
|
|
expect(result.diffSourceAfter).not.toEndWith("}\nmethod(): void {}\n");
|
|
});
|
|
|
|
test("delete removes the target chunk", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
const result = edit([{ op: "delete", ...ac }]);
|
|
|
|
expect(result.diffSourceAfter).not.toContain("run()");
|
|
expect(result.diffSourceAfter).toContain("constructor");
|
|
});
|
|
|
|
test("sibling chunk crc from before the batch still validates after an unrelated sibling is replaced first", () => {
|
|
const ctorCrc = getChecksum(testSource, "class_Worker.constructor");
|
|
const runCrc = getChecksum(testSource, "class_Worker.fn_run");
|
|
const result = applyChunkEdits({
|
|
source: testSource,
|
|
language: "typescript",
|
|
cwd: "/tmp",
|
|
filePath: "/tmp/worker.ts",
|
|
operations: [
|
|
{
|
|
op: "replace",
|
|
sel: "class_Worker.constructor",
|
|
crc: ctorCrc,
|
|
content: `\tconstructor(name: string) {\n\t\tthis.name = name.trim();\n\t}`,
|
|
},
|
|
{
|
|
op: "replace",
|
|
sel: "class_Worker.fn_run",
|
|
crc: runCrc,
|
|
content: `\trun(): void {\n\t\tconsole.log(this.name + "!");\n\t}`,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.parseValid).toBe(true);
|
|
expect(result.diffSourceAfter).toContain("name.trim()");
|
|
expect(result.diffSourceAfter).toMatch(/this\.name\s*\+\s*"!"/);
|
|
});
|
|
|
|
test("prepend_child on branch inserts before existing members", () => {
|
|
const result = edit([
|
|
{
|
|
op: "prepend_child",
|
|
sel: "class_Worker",
|
|
content: `\tid = 0;`,
|
|
},
|
|
]);
|
|
|
|
const after = result.diffSourceAfter;
|
|
expect(after).toContain("id = 0");
|
|
const idPos = after.indexOf("id = 0");
|
|
const ctorPos = after.indexOf("constructor");
|
|
expect(idPos).toBeGreaterThan(-1);
|
|
expect(idPos).toBeLessThan(ctorPos);
|
|
});
|
|
});
|
|
|
|
describe("insertion boundaries", () => {
|
|
test("keeps prepend_child separated from the first existing TypeScript class member", () => {
|
|
const source = `class Box<T> {\n value(): T {\n return this.current;\n }\n}\n`;
|
|
const result = applyEdit({
|
|
source,
|
|
language: "typescript",
|
|
filePath: "/tmp/box.ts",
|
|
operations: [
|
|
{
|
|
op: "prepend_child",
|
|
sel: "class_Box",
|
|
content: ` items(): T[] {\n return [];\n }`,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.diffSourceAfter).toContain(" }\n\n value(): T {");
|
|
expect(result.diffSourceAfter).not.toContain("}\n value(): T {");
|
|
});
|
|
|
|
test("keeps prepend_child separated from the first existing Rust impl member", () => {
|
|
const source = `impl Widget {\n fn old(&self) -> bool {\n true\n }\n}\n`;
|
|
const result = applyEdit({
|
|
source,
|
|
language: "rust",
|
|
filePath: "/tmp/widget.rs",
|
|
operations: [
|
|
{
|
|
op: "prepend_child",
|
|
sel: "impl_Widget",
|
|
content: ` fn build(&self) -> bool {\n false\n }`,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.diffSourceAfter).toContain(" }\n\n fn old(&self) -> bool {");
|
|
expect(result.diffSourceAfter).not.toContain("}\n fn old(&self) -> bool {");
|
|
});
|
|
|
|
test("keeps prepend_sibling separated before a Go top-level function", () => {
|
|
const source = `package main\n\nfunc format() {}\n`;
|
|
const result = applyEdit({
|
|
source,
|
|
language: "go",
|
|
filePath: "/tmp/format.go",
|
|
operations: [
|
|
{
|
|
op: "prepend_sibling",
|
|
sel: "fn_format",
|
|
content: "func formatLog() {}",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.diffSourceAfter).toContain("func formatLog() {}\n\nfunc format() {}");
|
|
expect(result.diffSourceAfter).not.toContain("func formatLog() {}\nfunc format() {}");
|
|
});
|
|
|
|
test("keeps append_sibling separated from the next Go top-level function", () => {
|
|
const source = `package main\n\nfunc first() {}\nfunc second() {}\n`;
|
|
const result = applyEdit({
|
|
source,
|
|
language: "go",
|
|
filePath: "/tmp/functions.go",
|
|
operations: [
|
|
{
|
|
op: "append_sibling",
|
|
sel: "fn_first",
|
|
content: "func middle() {}",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.diffSourceAfter).toContain("func first() {}\n\nfunc middle() {}\n\nfunc second() {}");
|
|
expect(result.diffSourceAfter).not.toContain("func middle() {}\nfunc second() {}");
|
|
});
|
|
|
|
test("append_child on a Go receiver type inserts after the last receiver method", () => {
|
|
const source = `package main\n\ntype Server struct {}\n\nfunc (s *Server) Start() {}\nfunc (s *Server) Stop() {}\n`;
|
|
const result = applyEdit({
|
|
source,
|
|
language: "go",
|
|
filePath: "/tmp/server.go",
|
|
operations: [
|
|
{
|
|
op: "append_child",
|
|
sel: "type_Server",
|
|
content: "func (s *Server) Restart() {}",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.diffSourceAfter).toContain(
|
|
"func (s *Server) Start() {}\nfunc (s *Server) Stop() {}\n\nfunc (s *Server) Restart() {}",
|
|
);
|
|
expect(result.diffSourceAfter).not.toContain("type Server struct {\nfunc (s *Server) Restart() {}");
|
|
});
|
|
|
|
test("append_child on Go type_Server with struct fields still inserts file-scope func at column 0", () => {
|
|
const source = `package main
|
|
|
|
type Server struct {
|
|
Addr string
|
|
}
|
|
`;
|
|
const crc = parseChunkTree(source, "go").chunks.find(c => c.path === "type_Server")?.checksum;
|
|
expect(crc).toBeDefined();
|
|
const result = applyEdit({
|
|
source,
|
|
language: "go",
|
|
filePath: "/tmp/server.go",
|
|
operations: [
|
|
{
|
|
op: "append_child",
|
|
sel: "type_Server",
|
|
crc,
|
|
content: "func (s *Server) Ping() {}",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.parseValid).toBe(true);
|
|
expect(result.diffSourceAfter).toContain("func (s *Server) Ping() {}");
|
|
expect(result.diffSourceAfter).not.toMatch(/Addr string\n[ \t]+func \(s \*Server\) Ping/);
|
|
});
|
|
|
|
test("append_child on Go type_Server keeps receiver method body indentation relative to column 0", () => {
|
|
const source = `package main
|
|
|
|
type Server struct {
|
|
Addr string
|
|
}
|
|
`;
|
|
const crc = parseChunkTree(source, "go").chunks.find(c => c.path === "type_Server")?.checksum;
|
|
expect(crc).toBeDefined();
|
|
const result = applyEdit({
|
|
source,
|
|
language: "go",
|
|
filePath: "/tmp/server.go",
|
|
operations: [
|
|
{
|
|
op: "append_child",
|
|
sel: "type_Server",
|
|
crc,
|
|
content: "func (s *Server) LogCount() int {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\treturn 0\n}",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.parseValid).toBe(true);
|
|
expect(result.diffSourceAfter).toContain(
|
|
"\nfunc (s *Server) LogCount() int {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\treturn 0\n}\n",
|
|
);
|
|
expect(result.diffSourceAfter).not.toContain("\n\tfunc (s *Server) LogCount() int {");
|
|
});
|
|
test("keeps append_child separated from the closing delimiter when adding the last child", () => {
|
|
const result = edit([
|
|
{
|
|
op: "append_child",
|
|
sel: "class_Worker",
|
|
content: '\tstatus(): string {\n\t\treturn "active";\n\t}',
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).toContain('\t}\n\n\tstatus(): string {\n\t\treturn "active";\n\t}\n}');
|
|
expect(result.diffSourceAfter).not.toContain("\t}\n\tstatus(): string {");
|
|
});
|
|
|
|
test("delete of last impl method collapses extra whitespace-only lines before the closing brace", () => {
|
|
const source = `impl S {
|
|
fn a() {}
|
|
|
|
fn b() {}
|
|
|
|
}
|
|
`;
|
|
const crc = getChecksum(source, "impl_S.fn_b", "rust");
|
|
const result = applyEdit({
|
|
source,
|
|
language: "rust",
|
|
filePath: "/tmp/impl.rs",
|
|
operations: [{ op: "delete", sel: "impl_S.fn_b", crc }],
|
|
});
|
|
|
|
expect(result.diffSourceAfter).toBe("impl S {\n fn a() {}\n\n}\n");
|
|
});
|
|
});
|
|
|
|
describe("edit safety invariants", () => {
|
|
const runChunkPath = "class_Worker.fn_run";
|
|
|
|
function buildStaleRunFixture(): { source: string; staleChecksum: string; currentChecksum: string } {
|
|
const staleChecksum = getChecksum(testSource, runChunkPath);
|
|
const source = edit([
|
|
{
|
|
op: "replace",
|
|
sel: runChunkPath,
|
|
crc: staleChecksum,
|
|
content: '\trun(): void {\n\t\tconsole.log("updated");\n\t}',
|
|
},
|
|
]).diffSourceAfter;
|
|
return {
|
|
source,
|
|
staleChecksum,
|
|
currentChecksum: getChecksum(source, runChunkPath),
|
|
};
|
|
}
|
|
|
|
for (const operation of ["replace", "delete", "splice"] as const) {
|
|
test(`rejects stale checksum for ${operation} with current and provided checksums in the error`, () => {
|
|
const { source, staleChecksum, currentChecksum } = buildStaleRunFixture();
|
|
|
|
const invoke = () => {
|
|
if (operation === "replace") {
|
|
return edit(
|
|
[
|
|
{
|
|
op: "replace",
|
|
sel: runChunkPath,
|
|
crc: staleChecksum,
|
|
content: '\trun(): void {\n\t\tconsole.log("again");\n\t}',
|
|
},
|
|
],
|
|
source,
|
|
);
|
|
}
|
|
if (operation === "delete") {
|
|
return edit([{ op: "delete", sel: runChunkPath, crc: staleChecksum }], source);
|
|
}
|
|
return edit(
|
|
[
|
|
{
|
|
op: "splice",
|
|
sel: runChunkPath,
|
|
crc: staleChecksum,
|
|
beg: 7,
|
|
end: 7,
|
|
content: '\t\tconsole.log("again");',
|
|
},
|
|
],
|
|
source,
|
|
);
|
|
};
|
|
|
|
expect(invoke).toThrow(new RegExp(`expected "${currentChecksum}", got "${staleChecksum}"`));
|
|
});
|
|
}
|
|
|
|
test("rejects a second same-path replace in one batch when the checksum stays stale", () => {
|
|
const checksum = getChecksum(testSource, runChunkPath);
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "replace",
|
|
sel: runChunkPath,
|
|
crc: checksum,
|
|
content: '\trun(): void {\n\t\tconsole.log("first");\n\t}',
|
|
},
|
|
{
|
|
op: "replace",
|
|
sel: runChunkPath,
|
|
crc: checksum,
|
|
content: '\trun(): void {\n\t\tconsole.log("second");\n\t}',
|
|
},
|
|
]),
|
|
).toThrow(/changed by an earlier batch operation/);
|
|
});
|
|
|
|
test("applies two same-path replaces in one batch when the second checksum matches the post-first state", () => {
|
|
const checksum = getChecksum(testSource, runChunkPath);
|
|
const firstContent = '\trun(): void {\n\t\tconsole.log("first");\n\t}';
|
|
const afterFirst = edit([
|
|
{ op: "replace", sel: runChunkPath, crc: checksum, content: firstContent },
|
|
]).diffSourceAfter;
|
|
const checksum2 = getChecksum(afterFirst, runChunkPath);
|
|
const result = edit([
|
|
{ op: "replace", sel: runChunkPath, crc: checksum, content: firstContent },
|
|
{
|
|
op: "replace",
|
|
sel: runChunkPath,
|
|
crc: checksum2,
|
|
content: '\trun(): void {\n\t\tconsole.log("second");\n\t}',
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).toContain('console.log("second")');
|
|
expect(result.diffSourceAfter).not.toContain('console.log("first")');
|
|
});
|
|
|
|
test("rejects a second same-path splice in one batch when the checksum stays stale", () => {
|
|
const checksum = getChecksum(testSource, runChunkPath);
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "splice",
|
|
sel: runChunkPath,
|
|
crc: checksum,
|
|
beg: 6,
|
|
end: 6,
|
|
content: '\trun(task = "default"): void {',
|
|
},
|
|
{
|
|
op: "splice",
|
|
sel: runChunkPath,
|
|
crc: checksum,
|
|
beg: 7,
|
|
end: 7,
|
|
content: "\t\tconsole.log(task);",
|
|
},
|
|
]),
|
|
).toThrow(/changed by an earlier batch operation/);
|
|
});
|
|
|
|
test("applies two same-path splices in one batch when the second checksum matches the post-first state", () => {
|
|
const checksum = getChecksum(testSource, runChunkPath);
|
|
// Batch splices are applied bottom-up by absolute file line (higher line first).
|
|
const afterFirst = edit([
|
|
{
|
|
op: "splice",
|
|
sel: runChunkPath,
|
|
crc: checksum,
|
|
beg: 7,
|
|
end: 7,
|
|
content: "\t\tconsole.log(task);",
|
|
},
|
|
]).diffSourceAfter;
|
|
const checksum2 = getChecksum(afterFirst, runChunkPath);
|
|
const result = edit([
|
|
{
|
|
op: "splice",
|
|
sel: runChunkPath,
|
|
crc: checksum,
|
|
beg: 7,
|
|
end: 7,
|
|
content: "\t\tconsole.log(task);",
|
|
},
|
|
{
|
|
op: "splice",
|
|
sel: runChunkPath,
|
|
crc: checksum2,
|
|
beg: 6,
|
|
end: 6,
|
|
content: '\trun(task = "default"): void {',
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).toContain('run(task = "default"): void {');
|
|
expect(result.diffSourceAfter).toContain("\t\tconsole.log(task);");
|
|
expect(result.diffSourceAfter).not.toContain("console.log(this.name)");
|
|
});
|
|
|
|
test("reports batch rollback when a later operation is invalid", () => {
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "append_child",
|
|
sel: "class_Worker",
|
|
content: '\tstatus(): string {\n\t\treturn "active";\n\t}',
|
|
},
|
|
{
|
|
op: "delete",
|
|
sel: "class_Worker.fn_run",
|
|
crc: "ZZZZ",
|
|
},
|
|
]),
|
|
).toThrow(/Edit operation 2\/2 failed.*Checksum mismatch/s);
|
|
});
|
|
|
|
test("keeps untouched sibling checksums stable after a nearby edit", () => {
|
|
const before = getChecksum(testSource, "class_Worker.constructor");
|
|
const after = edit([
|
|
{
|
|
op: "splice",
|
|
sel: runChunkPath,
|
|
crc: getChecksum(testSource, runChunkPath),
|
|
beg: 7,
|
|
end: 7,
|
|
content: '\t\tconsole.log("nearby");',
|
|
},
|
|
]).diffSourceAfter;
|
|
|
|
expect(getChecksum(after, "class_Worker.constructor")).toBe(before);
|
|
});
|
|
test("preserves a chunk checksum after unrelated file changes outside the chunk", () => {
|
|
const checksum = getChecksum(testSource, runChunkPath);
|
|
const sourceWithExternalTail = `${testSource}// externally appended line\n`;
|
|
|
|
const result = edit(
|
|
[
|
|
{
|
|
op: "replace",
|
|
sel: runChunkPath,
|
|
crc: checksum,
|
|
content: '\trun(): void {\n\t\tconsole.log("updated");\n\t}',
|
|
},
|
|
],
|
|
sourceWithExternalTail,
|
|
);
|
|
|
|
expect(result.diffSourceAfter).toContain("// externally appended line");
|
|
expect(result.diffSourceAfter).toContain('console.log("updated")');
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Content prefix stripping
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("content prefix stripping", () => {
|
|
test("line-number prefixes are stripped from replacement content", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
const result = edit([
|
|
{
|
|
op: "replace",
|
|
...ac,
|
|
content: ` 120 │ \texec(): void {\n 121 │ \t\tconsole.log("exec");\n 122 │ \t}`,
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).not.toMatch(/\d+\s*[|│]/);
|
|
expect(result.diffSourceAfter).toContain("exec()");
|
|
});
|
|
|
|
test("diff and hashline prefixes are stripped from replacement content", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
const result = edit([
|
|
{
|
|
op: "replace",
|
|
...ac,
|
|
content: '+1#ZP:exec(): void {\n+2#PM:\tconsole.log("exec");\n+3#QV:}',
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).not.toContain("#ZP:");
|
|
expect(result.diffSourceAfter).not.toContain("\n+");
|
|
expect(result.diffSourceAfter).toContain("exec(): void");
|
|
});
|
|
});
|
|
|
|
describe("chunk path resolution errors", () => {
|
|
test("unknown leaf under an existing parent lists every direct child path", () => {
|
|
let message = "";
|
|
try {
|
|
applyEdit({
|
|
source: testSource,
|
|
language: "typescript",
|
|
filePath: "/tmp/worker.ts",
|
|
operations: [
|
|
{
|
|
op: "prepend_sibling",
|
|
sel: "class_Worker.fn_ghost",
|
|
content: "\tghost(): void {}",
|
|
},
|
|
],
|
|
});
|
|
} catch (err) {
|
|
message = (err as Error).message;
|
|
}
|
|
expect(message).toContain('Direct children of "class_Worker"');
|
|
expect(message).toContain("class_Worker.constructor");
|
|
expect(message).toContain("class_Worker.fn_run");
|
|
});
|
|
});
|
|
|
|
describe("formatChunkedRead", () => {
|
|
test("root read uses recursive chunk headers without legacy labels", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "worker.ts");
|
|
await Bun.write(filePath, `${testSource}\nconst READY = true;\n`);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: filePath,
|
|
cwd: tmpDir,
|
|
language: "typescript",
|
|
});
|
|
|
|
expect(result.text).toContain("worker.ts ·");
|
|
expect(result.text).toContain(":class_Worker#");
|
|
expect(result.text).toContain(".fn_run#");
|
|
expect(result.text).not.toContain("ck:");
|
|
expect(result.text).not.toContain("[branch");
|
|
});
|
|
|
|
test("root read header reports the full file line count", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "with-tail.ts");
|
|
const body = `${testSource}\n${Array.from({ length: 40 }, () => "// trailing\n").join("")}`;
|
|
await Bun.write(filePath, body);
|
|
const totalLines = body.split("\n").length;
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: filePath,
|
|
cwd: tmpDir,
|
|
language: "typescript",
|
|
});
|
|
|
|
expect(result.text).toMatch(new RegExp(`with-tail\\.ts · ${totalLines} lines`));
|
|
});
|
|
|
|
test("leaf read shows absolute file lines and raw source indentation", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "worker.ts");
|
|
await Bun.write(filePath, testSource);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: `${filePath}:class_Worker.fn_run`,
|
|
cwd: tmpDir,
|
|
language: "typescript",
|
|
});
|
|
|
|
expect(result.text).toContain("worker.ts:class_Worker.fn_run ·");
|
|
expect(result.text).toContain("run(): void {");
|
|
expect(result.text).toContain("6 │ ");
|
|
expect(result.text).toContain("run(): void {");
|
|
expect(result.text).toContain("7 │ ");
|
|
expect(result.text).toContain("console.log(this.name);");
|
|
});
|
|
|
|
test("large child leaves stay expanded under the default preview threshold", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "service.ts");
|
|
const longBody = Array.from({ length: 40 }, (_, index) => ` step(${index});`).join("\n");
|
|
await Bun.write(filePath, `class Service {\n handle(): void {\n${longBody}\n done();\n }\n}\n`);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: `${filePath}:class_Service.fn_handle`,
|
|
cwd: tmpDir,
|
|
language: "typescript",
|
|
});
|
|
|
|
expect(result.text).not.toContain("to expand ⋮");
|
|
expect(result.text).toContain("3 │ step(0);");
|
|
expect(result.text).toContain("27 │ step(24);");
|
|
expect(result.text).toContain("done();");
|
|
});
|
|
});
|
|
|
|
describe("leaf insert indentation", () => {
|
|
test("prepend_sibling on a nested method uses the method's indent level", () => {
|
|
const result = edit([
|
|
{
|
|
op: "prepend_sibling",
|
|
sel: "class_Worker.fn_run",
|
|
content: "validate(): boolean {\n\treturn true;\n}",
|
|
},
|
|
]);
|
|
|
|
const after = result.diffSourceAfter;
|
|
expect(after).toContain("\tvalidate(): boolean {");
|
|
expect(after).toContain("\trun(): void {");
|
|
const validatePos = after.indexOf("validate()");
|
|
const runPos = after.indexOf("run()");
|
|
expect(validatePos).toBeLessThan(runPos);
|
|
});
|
|
|
|
test("append_sibling on a nested method uses the method's indent level", () => {
|
|
const result = edit([
|
|
{
|
|
op: "append_sibling",
|
|
sel: "class_Worker.fn_run",
|
|
content: 'stop(): void {\n\tconsole.log("stopped");\n}',
|
|
},
|
|
]);
|
|
|
|
const after = result.diffSourceAfter;
|
|
expect(after).toContain("\tstop(): void {");
|
|
const runPos = after.indexOf("run()");
|
|
const stopPos = after.indexOf("stop()");
|
|
expect(stopPos).toBeGreaterThan(runPos);
|
|
});
|
|
});
|
|
|
|
describe("replace last child formatting", () => {
|
|
test("replacing last method does not merge with closing brace", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
const result = edit([
|
|
{
|
|
op: "replace",
|
|
...ac,
|
|
content: '\texec(): void {\n\t\tconsole.log("exec");\n\t}',
|
|
},
|
|
]);
|
|
|
|
const after = result.diffSourceAfter;
|
|
expect(after).not.toContain("}}");
|
|
expect(after).toContain("\texec(): void {");
|
|
expect(after).toMatch(/\t\}\n\}/);
|
|
});
|
|
|
|
test("replace dedents uniformly over-indented content so the chunk column is not applied twice", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
const result = edit([
|
|
{
|
|
op: "replace",
|
|
...ac,
|
|
content: '\t\texec(): void {\n\t\t\tconsole.log("exec");\n\t\t}',
|
|
},
|
|
]);
|
|
|
|
const after = result.diffSourceAfter;
|
|
expect(after).toContain("\texec(): void {");
|
|
expect(after).toContain('\t\tconsole.log("exec");');
|
|
expect(after).not.toContain("\t\texec(): void");
|
|
});
|
|
});
|
|
|
|
describe("addressable member rendering", () => {
|
|
test("renders trivial Rust enum variants as addressable children", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "log.rs");
|
|
await Bun.write(filePath, `pub enum LogLevel {\n Debug,\n Info,\n Warn,\n Error,\n}\n`);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: filePath,
|
|
cwd: tmpDir,
|
|
language: "rust",
|
|
});
|
|
|
|
expect(result.text).toContain(":enum_LogLevel#");
|
|
expect(result.text).toContain(".variant_Debug#");
|
|
expect(result.text).toContain(".variant_Error#");
|
|
});
|
|
|
|
test("renders a single-method Go interface inline on the parent chunk", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "handler.go");
|
|
await Bun.write(
|
|
filePath,
|
|
`package main\n\ntype Handler interface {\n Handle(method, path string) Result\n}\n`,
|
|
);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: filePath,
|
|
cwd: tmpDir,
|
|
language: "go",
|
|
});
|
|
|
|
expect(result.text).toContain(":type_Handler#");
|
|
expect(result.text).toContain("3 │ type Handler interface {");
|
|
expect(result.text).toContain("4 │ Handle(method, path string) Result");
|
|
});
|
|
|
|
test("renders Go receiver methods beneath their receiver type", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "server.go");
|
|
await Bun.write(
|
|
filePath,
|
|
`package main\n\ntype Server struct {\n Addr string\n}\n\nfunc (s *Server) Start() {}\nfunc (s Server) Stop() {}\n`,
|
|
);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: filePath,
|
|
cwd: tmpDir,
|
|
language: "go",
|
|
});
|
|
|
|
expect(result.text).toContain(":type_Server#");
|
|
expect(result.text).toContain(".field_Addr#");
|
|
expect(result.text).toContain(".fn_Start#");
|
|
expect(result.text).toContain(".fn_Stop#");
|
|
expect(result.text).not.toContain(":fn_Start#");
|
|
});
|
|
|
|
test("line range filter shows receiver methods under a type even when the range skips the type header", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "server.go");
|
|
await Bun.write(
|
|
filePath,
|
|
`package main\n\ntype Server struct {\n Addr string\n}\n\nfunc (s *Server) Start() {}\nfunc (s Server) Stop() {}\n`,
|
|
);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: `${filePath}:L7-L8`,
|
|
cwd: tmpDir,
|
|
language: "go",
|
|
});
|
|
|
|
expect(result.text).toContain("L7-L8");
|
|
expect(result.text).toContain(".fn_Start#");
|
|
expect(result.text).toContain(".fn_Stop#");
|
|
});
|
|
|
|
test("renders trivial TypeScript enum variants as addressable children", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "status.ts");
|
|
await Bun.write(filePath, `enum Status {\n Idle = "idle",\n Busy = "busy",\n}\n`);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: filePath,
|
|
cwd: tmpDir,
|
|
language: "typescript",
|
|
});
|
|
|
|
expect(result.text).toContain(":enum_Status#");
|
|
expect(result.text).toContain(".variant_Idle#");
|
|
expect(result.text).toContain(".variant_Busy#");
|
|
});
|
|
|
|
test("keeps non-trivial containers expanded", () => {
|
|
const classTree = parseChunkTree(
|
|
`class Tiny {\n foo() {\n return 1;\n }\n\n bar() {\n return 2;\n }\n}\n`,
|
|
"typescript",
|
|
);
|
|
const classChunk = classTree.chunks.find(chunk => chunk.path === "class_Tiny");
|
|
expect(classChunk?.children).toContain("class_Tiny.fn_foo");
|
|
expect(classChunk?.children).toContain("class_Tiny.fn_bar");
|
|
|
|
const enumSource = Array.from({ length: 35 }, (_, i) => ` V${i} = ${i},`).join("\n");
|
|
const enumTree = parseChunkTree(`enum Big {\n${enumSource}\n}\n`, "typescript");
|
|
const enumChunk = enumTree.chunks.find(chunk => chunk.path === "enum_Big");
|
|
expect(enumChunk?.kind).toBe("branch");
|
|
});
|
|
});
|
|
|
|
describe("grouped Go receiver chunk headers", () => {
|
|
test("reports grouped receiver chunk line counts from rendered lines", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-core-"));
|
|
const filePath = path.join(tmpDir, "server.go");
|
|
await Bun.write(
|
|
filePath,
|
|
`package main\n\ntype Server struct {\n Addr string\n}\n\nfunc (s *Server) Start() {}\nfunc (s Server) Stop() {}\n`,
|
|
);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: `${filePath}:type_Server`,
|
|
cwd: tmpDir,
|
|
language: "go",
|
|
});
|
|
|
|
expect(result.text).toContain("server.go:type_Server · 6 lines");
|
|
expect(result.text).toContain(".fn_Start#");
|
|
expect(result.text).toContain(".fn_Stop#");
|
|
});
|
|
});
|
|
|
|
describe("addressable member editing", () => {
|
|
const enumSource = `enum Status {\n Idle = "idle",\n Busy = "busy",\n}\n`;
|
|
|
|
test("replace accepts full-source edits on the parent enum container", () => {
|
|
const ac = { sel: "enum_Status", crc: getChecksum(enumSource, "enum_Status") };
|
|
const result = edit(
|
|
[
|
|
{
|
|
op: "replace",
|
|
...ac,
|
|
content: `enum Status {\n Idle = "idle",\n Done = "done",\n}\n`,
|
|
},
|
|
],
|
|
enumSource,
|
|
);
|
|
|
|
expect(result.diffSourceAfter).toContain('Done = "done"');
|
|
expect(result.diffSourceAfter).not.toContain('Busy = "busy"');
|
|
});
|
|
|
|
test("append_sibling inserts beside an individually addressable enum variant", () => {
|
|
const result = edit(
|
|
[
|
|
{
|
|
op: "append_sibling",
|
|
sel: "enum_Status.variant_Idle",
|
|
content: 'Paused = "paused",',
|
|
},
|
|
],
|
|
enumSource,
|
|
);
|
|
|
|
expect(result.diffSourceAfter).toContain(' Idle = "idle",\n\n Paused = "paused",\n\n Busy = "busy",');
|
|
});
|
|
|
|
test("delete removes an individually addressable enum variant", () => {
|
|
const busy = { sel: "enum_Status.variant_Busy", crc: getChecksum(enumSource, "enum_Status.variant_Busy") };
|
|
const result = edit([{ op: "delete", ...busy }], enumSource);
|
|
|
|
expect(result.diffSourceAfter).toContain('Idle = "idle"');
|
|
expect(result.diffSourceAfter).not.toContain('Busy = "busy"');
|
|
});
|
|
});
|
|
|
|
describe("zero-width splice (line insertion)", () => {
|
|
test("zero-width splice inserts before an absolute file line inside the chunk", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
const result = edit([
|
|
{
|
|
op: "splice",
|
|
...ac,
|
|
beg: 7,
|
|
end: 6,
|
|
content: "if (!this.name) return;",
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).toContain("\t\tif (!this.name) return;\n\t\tconsole.log(this.name);");
|
|
});
|
|
|
|
test("zero-width splice inserts after an absolute file line inside the chunk", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
const result = edit([
|
|
{
|
|
op: "splice",
|
|
...ac,
|
|
beg: 8,
|
|
end: 7,
|
|
content: "trackRun();",
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).toContain("\t\tconsole.log(this.name);\n\t\ttrackRun();\n\t}");
|
|
});
|
|
|
|
test("zero-width splice rejects gaps outside the chunk", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "splice",
|
|
...ac,
|
|
beg: 20,
|
|
end: 19,
|
|
content: "noop();",
|
|
},
|
|
]),
|
|
).toThrow(/Invalid zero-width splice L20-L19/);
|
|
});
|
|
test("zero-width splice after the chunk preserves the separator gap", () => {
|
|
const source = `class Worker {\n\trun(): void {\n\t\twork();\n\t}\n\n\tstop(): void {\n\t\tcleanup();\n\t}\n}\n`;
|
|
const checksum = getChecksum(source, "class_Worker.fn_run");
|
|
const inserted = edit(
|
|
[
|
|
{
|
|
op: "splice",
|
|
sel: "class_Worker.fn_run",
|
|
crc: checksum,
|
|
beg: 5,
|
|
end: 4,
|
|
content: "// inserted",
|
|
},
|
|
],
|
|
source,
|
|
);
|
|
|
|
expect(inserted.diffSourceAfter).toContain("\t}\n\t// inserted\n\n\tstop(): void {");
|
|
|
|
const stopChecksum = getChecksum(inserted.diffSourceAfter, "class_Worker.fn_stop");
|
|
const replaced = edit(
|
|
[
|
|
{
|
|
op: "replace",
|
|
sel: "class_Worker.fn_stop",
|
|
crc: stopChecksum,
|
|
content: "\tstop(): void {\n\t\tshutdown();\n\t}",
|
|
},
|
|
],
|
|
inserted.diffSourceAfter,
|
|
);
|
|
|
|
expect(replaced.diffSourceAfter).toContain("\t// inserted\n\n\tstop(): void {");
|
|
});
|
|
});
|
|
|
|
describe("Go receiver render ownership", () => {
|
|
test("omits unrelated top-level siblings from grouped receiver output", () => {
|
|
const source = `package main\n\ntype Server struct {\n Addr string\n}\n\nfunc (s *Server) Start() {}\nfunc (s Server) Stop() {}\n`;
|
|
const result = applyEdit({
|
|
source,
|
|
language: "go",
|
|
filePath: "/tmp/server.go",
|
|
operations: [
|
|
{
|
|
op: "prepend_sibling",
|
|
sel: "type_Server.fn_Start",
|
|
content: "func DefaultServer() *Server {\n return &Server{}\n}",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect((result.responseText.match(/func DefaultServer\(\) \*Server/g) ?? []).length).toBe(1);
|
|
expect(result.responseText).toContain(":fn_DefaultServer#");
|
|
expect(result.responseText).toContain("<.fn_Start#");
|
|
});
|
|
});
|
|
|
|
describe("blank-line cleanup", () => {
|
|
const commentedSource = `class Worker {
|
|
stop(): void {
|
|
cleanup();
|
|
}
|
|
|
|
// restart note
|
|
restart(): void {
|
|
boot();
|
|
}
|
|
}
|
|
`;
|
|
|
|
test("splice deletion collapses triple newlines at the edit seam", () => {
|
|
const checksum = getChecksum(commentedSource, "class_Worker.fn_restart");
|
|
const result = edit(
|
|
[
|
|
{
|
|
op: "splice",
|
|
sel: "class_Worker.fn_restart",
|
|
crc: checksum,
|
|
beg: 6,
|
|
end: 6,
|
|
content: "",
|
|
},
|
|
],
|
|
commentedSource,
|
|
);
|
|
|
|
expect(result.diffSourceAfter).toContain("\t}\n\n\trestart(): void {");
|
|
expect(result.diffSourceAfter).not.toContain("\t}\n\n\n\trestart(): void {");
|
|
});
|
|
|
|
test("replace with empty content collapses triple newlines at the edit seam", () => {
|
|
const checksum = getChecksum(commentedSource, "class_Worker.fn_restart");
|
|
const result = edit(
|
|
[
|
|
{
|
|
op: "replace",
|
|
sel: "class_Worker.fn_restart",
|
|
crc: checksum,
|
|
content: "",
|
|
},
|
|
],
|
|
commentedSource,
|
|
);
|
|
|
|
expect(result.diffSourceAfter).toContain("\t}\n\n}");
|
|
expect(result.diffSourceAfter).not.toContain("\t}\n\n\n}");
|
|
});
|
|
|
|
test("delete collapses triple newlines at the edit seam", () => {
|
|
const checksum = getChecksum(commentedSource, "class_Worker.fn_restart");
|
|
const result = edit(
|
|
[
|
|
{
|
|
op: "delete",
|
|
sel: "class_Worker.fn_restart",
|
|
crc: checksum,
|
|
},
|
|
],
|
|
commentedSource,
|
|
);
|
|
|
|
expect(result.diffSourceAfter).toContain("\t}\n\n}");
|
|
expect(result.diffSourceAfter).not.toContain("\t}\n\n\n}");
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// splice
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("splice", () => {
|
|
test("replaces a line subrange within a chunk using absolute file lines", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
// fn_run spans file lines 6-8; line 7 is console.log
|
|
const result = edit([
|
|
{
|
|
op: "splice",
|
|
...ac,
|
|
beg: 7,
|
|
end: 7,
|
|
content: '\t\tconsole.log("updated");',
|
|
},
|
|
]);
|
|
|
|
expect(result.diffSourceAfter).toContain('"updated"');
|
|
expect(result.diffSourceAfter).not.toContain("console.log(this.name)");
|
|
expect(result.diffSourceAfter).toContain("run(): void {");
|
|
});
|
|
|
|
test("splice reindents zero-indented content against the replaced range", () => {
|
|
const source = `impl Widget {\n fn old(&self) -> bool {\n true\n }\n}\n`;
|
|
const checksum = getChecksum(source, "impl_Widget.fn_old", "rust");
|
|
const result = applyEdit({
|
|
source,
|
|
language: "rust",
|
|
filePath: "/tmp/widget.rs",
|
|
operations: [
|
|
{
|
|
op: "splice",
|
|
sel: "impl_Widget.fn_old",
|
|
crc: checksum,
|
|
beg: 3,
|
|
end: 4,
|
|
content: " false\n}",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.diffSourceAfter).toContain(" fn old(&self) -> bool {\n false\n }\n");
|
|
expect(result.diffSourceAfter).not.toContain(" false");
|
|
});
|
|
|
|
test("splice with wrong checksum throws", () => {
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "splice",
|
|
sel: "class_Worker.fn_run",
|
|
crc: "ZZZZ",
|
|
beg: 7,
|
|
end: 7,
|
|
content: "replacement",
|
|
},
|
|
]),
|
|
).toThrow(/mismatch/i);
|
|
});
|
|
|
|
test("splice rejects reversed ranges that are not zero-width gaps", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "splice",
|
|
...ac,
|
|
beg: 4,
|
|
end: 2,
|
|
content: "replacement",
|
|
},
|
|
]),
|
|
).toThrow(/Invalid splice range L4-L2/);
|
|
});
|
|
|
|
test("splice rejects line 0 before edit application", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "splice",
|
|
...ac,
|
|
beg: 0,
|
|
end: 1,
|
|
content: "replacement",
|
|
},
|
|
]),
|
|
).toThrow(/Splice beg 0 is invalid/);
|
|
});
|
|
|
|
test("splice with out-of-range lines throws", () => {
|
|
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
|
|
// fn_run spans file lines 6-8; L1-L5 does not overlap
|
|
expect(() =>
|
|
edit([
|
|
{
|
|
op: "splice",
|
|
...ac,
|
|
beg: 1,
|
|
end: 5,
|
|
content: "replacement",
|
|
},
|
|
]),
|
|
).toThrow(/outside/i);
|
|
});
|
|
});
|
|
|
|
describe("prepend_child warnings", () => {
|
|
test("warns when comment-only prepend_child may merge into the next chunk", () => {
|
|
const source = `package main\n\nimport "fmt"\n`;
|
|
const tree = parseChunkTree(source, "go");
|
|
const root = tree.chunks.find(c => c.path === "");
|
|
if (!root) {
|
|
throw new Error("expected root chunk");
|
|
}
|
|
const result = applyChunkEdits({
|
|
source,
|
|
language: "go",
|
|
cwd: "/",
|
|
filePath: "main.go",
|
|
operations: [{ op: "prepend_child", sel: "", crc: root.checksum, content: "// AUTO-GENERATED\n" }],
|
|
});
|
|
expect(result.warnings.some(w => w.includes("Comment-only prepend_child"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("chunk selector auto-resolution", () => {
|
|
test("warns on suffix auto-resolution", () => {
|
|
const result = edit([
|
|
{
|
|
op: "replace",
|
|
sel: "fn_run",
|
|
crc: getChecksum(testSource, "class_Worker.fn_run"),
|
|
content: "run(): void {\n\tconsole.log(this.name);\n}",
|
|
},
|
|
]);
|
|
expect(
|
|
result.warnings.some(w => w.includes('Auto-resolved chunk selector "fn_run" to "class_Worker.fn_run"')),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("warns on prefix auto-resolution", () => {
|
|
const result = edit([
|
|
{
|
|
op: "replace",
|
|
sel: "run",
|
|
crc: getChecksum(testSource, "class_Worker.fn_run"),
|
|
content: "run(): void {\n\tconsole.log(this.name);\n}",
|
|
},
|
|
]);
|
|
expect(result.warnings.some(w => w.includes('Auto-resolved chunk selector "run" to "class_Worker.fn_run"'))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("errors on ambiguous suffix matches", () => {
|
|
// Source with two identically-named nested chunks under different parents
|
|
const source = `class Foo {\n\trun(): void {}\n}\nclass Bar {\n\trun(): void {}\n}\n`;
|
|
expect(() =>
|
|
applyEdit({
|
|
source,
|
|
language: "typescript",
|
|
operations: [
|
|
{
|
|
op: "delete",
|
|
sel: "fn_run",
|
|
crc: getChecksum(source, "class_Foo.fn_run"),
|
|
},
|
|
],
|
|
}),
|
|
).toThrow(/Ambiguous chunk selector "fn_run" matches 2 chunks/);
|
|
});
|
|
});
|
|
|
|
describe("prepend_child file_preamble guard", () => {
|
|
test("errors when comment-only prepend_child targets root with file_preamble", () => {
|
|
// JS source with a leading comment block that becomes file_preamble
|
|
const source = `/**\n * License header\n */\nconst x = 1;\n`;
|
|
const tree = parseChunkTree(source, "javascript");
|
|
const hasPreamble = tree.chunks.some(c => c.path === "file_preamble");
|
|
if (!hasPreamble) {
|
|
// Skip if parser doesn't produce file_preamble for this source
|
|
return;
|
|
}
|
|
const root = tree.chunks.find(c => c.path === "")!;
|
|
expect(() =>
|
|
applyChunkEdits({
|
|
source,
|
|
language: "javascript",
|
|
cwd: "/",
|
|
filePath: "index.js",
|
|
operations: [{ op: "prepend_child", sel: "", crc: root.checksum, content: "// AUTO-GENERATED\n" }],
|
|
}),
|
|
).toThrow(/Comment-only prepend_child on root is not allowed when the file has a file_preamble/);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// move
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("tlaplus chunk rendering", () => {
|
|
const tlaplusSource = `---- MODULE Spec ----\nVARIABLE x\n\nInit == x = 0\n\n(* --algorithm Demo\nvariables x = 0;\nbegin\n Inc:\n x := x + 1;\nend algorithm; *)\n\\* BEGIN TRANSLATION\nVARIABLES pc\nNext == pc' = pc\n\\* END TRANSLATION\n====\n`;
|
|
|
|
test("formatChunkedRead hides translated content behind a synthetic translation chunk", async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-tree-tlaplus-read-"));
|
|
const filePath = path.join(tmpDir, "Spec.tla");
|
|
await Bun.write(filePath, tlaplusSource);
|
|
|
|
const result = await formatChunkedRead({
|
|
filePath,
|
|
readPath: filePath,
|
|
cwd: tmpDir,
|
|
language: "tlaplus",
|
|
});
|
|
|
|
expect(result.text).toContain(".translation_12#");
|
|
expect(result.text).toContain("\\* [translation hidden]");
|
|
expect(result.text).not.toContain("Next == pc' = pc");
|
|
});
|
|
|
|
test("applyChunkEdits keeps translated content hidden in responseText", () => {
|
|
const tree = parseChunkTree(tlaplusSource, "tlaplus");
|
|
const initChunk = tree.chunks.find(chunk => chunk.path.endsWith("operator_Init"));
|
|
if (!initChunk) throw new Error("Expected operator_Init chunk in tlaplus fixture");
|
|
|
|
const result = applyChunkEdits({
|
|
source: tlaplusSource,
|
|
language: "tlaplus",
|
|
cwd: "/tmp",
|
|
filePath: "/tmp/Spec.tla",
|
|
operations: [{ op: "replace", sel: initChunk.path, crc: initChunk.checksum, content: "Start == x = 0" }],
|
|
});
|
|
|
|
expect(result.diffSourceAfter).toContain("Start == x = 0");
|
|
expect(result.responseText).toContain(".translation_12#");
|
|
expect(result.responseText).toContain("\\* [translation hidden]");
|
|
expect(result.responseText).not.toContain("Next == pc' = pc");
|
|
});
|
|
});
|