Files
oh-my-pi/packages/coding-agent/test/core/hashline.test.ts
T
can1357 193724d1d4 refactor(coding-agent): restructured hashline format and simplified test infrastructure
- Changed hashline format separator from two spaces to pipe character (|) throughout codebase for consistency.
- Simplified ollama installation checks in test files by replacing execSync with Bun.which() and inverting PI_NO_LOCAL_LLM to PI_LOCAL_LLM environment variable.
- Added early return guards in test beforeAll hooks to skip tests when required tools (ollama, magick) are unavailable.
- Added debug instrumentation to system prompt building via PI_DEBUG_STARTUP environment variable for startup performance monitoring.
- Removed prompt cache retention assertion from openai-codex streaming test.
2026-02-12 00:12:59 +01:00

729 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import {
applyHashlineEdits,
computeLineHash,
formatHashLines,
type HashlineEdit,
HashlineMismatchError,
parseLineRef,
streamHashLinesFromLines,
streamHashLinesFromUtf8,
validateLineRef,
} from "@oh-my-pi/pi-coding-agent/patch";
// ═══════════════════════════════════════════════════════════════════════════
// computeLineHash
// ═══════════════════════════════════════════════════════════════════════════
describe("computeLineHash", () => {
test("returns 2-4 character alphanumeric hash string", () => {
const hash = computeLineHash(1, "hello");
expect(hash).toMatch(/^[0-9a-z]{2,4}$/);
});
test("same content at same line produces same hash", () => {
const a = computeLineHash(1, "hello");
const b = computeLineHash(1, "hello");
expect(a).toBe(b);
});
test("different content produces different hash", () => {
const a = computeLineHash(1, "hello");
const b = computeLineHash(1, "world");
expect(a).not.toBe(b);
});
test("empty line produces valid hash", () => {
const hash = computeLineHash(1, "");
expect(hash).toMatch(/^[0-9a-z]{2,4}$/);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// formatHashLines
// ═══════════════════════════════════════════════════════════════════════════
describe("formatHashLines", () => {
test("formats single line", () => {
const result = formatHashLines("hello");
const hash = computeLineHash(1, "hello");
expect(result).toBe(`1:${hash}|hello`);
});
test("formats multiple lines with 1-indexed numbers", () => {
const result = formatHashLines("foo\nbar\nbaz");
const lines = result.split("\n");
expect(lines).toHaveLength(3);
expect(lines[0]).toStartWith("1:");
expect(lines[1]).toStartWith("2:");
expect(lines[2]).toStartWith("3:");
});
test("respects custom startLine", () => {
const result = formatHashLines("foo\nbar", 10);
const lines = result.split("\n");
expect(lines[0]).toStartWith("10:");
expect(lines[1]).toStartWith("11:");
});
test("handles empty lines in content", () => {
const result = formatHashLines("foo\n\nbar");
const lines = result.split("\n");
expect(lines).toHaveLength(3);
expect(lines[1]).toMatch(/^2:[0-9a-z]{2,4}\|$/);
});
test("round-trips with computeLineHash", () => {
const content = "function hello() {\n return 42;\n}";
const formatted = formatHashLines(content);
const lines = formatted.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(\d+):([0-9a-z]+)\|(.*)$/);
expect(match).not.toBeNull();
const lineNum = Number.parseInt(match![1], 10);
const hash = match![2];
const lineContent = match![3];
expect(computeLineHash(lineNum, lineContent)).toBe(hash);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// streamHashLinesFromUtf8 / streamHashLinesFromLines
// ═══════════════════════════════════════════════════════════════════════════
describe("streamHashLinesFrom*", () => {
async function collectText(gen: AsyncIterable<string>): Promise<string> {
const parts: string[] = [];
for await (const part of gen) {
parts.push(part);
}
return parts.join("\n");
}
async function* utf8Chunks(text: string, chunkSize: number): AsyncGenerator<Uint8Array> {
const bytes = new TextEncoder().encode(text);
for (let i = 0; i < bytes.length; i += chunkSize) {
yield bytes.slice(i, i + chunkSize);
}
}
test("streamHashLinesFromUtf8 matches formatHashLines", async () => {
const content = "foo\nbar\nbaz";
const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 }));
expect(streamed).toBe(formatHashLines(content));
});
test("streamHashLinesFromUtf8 handles empty content", async () => {
const content = "";
const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 }));
expect(streamed).toBe(formatHashLines(content));
});
test("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => {
const content = "foo\nbar\n";
const lines = ["foo", "bar", ""]; // match `content.split("\\n")`
const streamed = await collectText(streamHashLinesFromLines(lines, { maxChunkLines: 2 }));
expect(streamed).toBe(formatHashLines(content));
});
test("chunking respects maxChunkLines", async () => {
const content = "a\nb\nc";
const parts: string[] = [];
for await (const part of streamHashLinesFromUtf8(utf8Chunks(content, 1), {
maxChunkLines: 1,
maxChunkBytes: 1024,
})) {
parts.push(part);
}
expect(parts).toHaveLength(3);
expect(parts.join("\n")).toBe(formatHashLines(content));
});
});
// ═══════════════════════════════════════════════════════════════════════════
// parseLineRef
// ═══════════════════════════════════════════════════════════════════════════
describe("parseLineRef", () => {
test("parses valid reference", () => {
const ref = parseLineRef("5:abcd");
expect(ref).toEqual({ line: 5, hash: "abcd" });
});
test("parses single-digit hash", () => {
const ref = parseLineRef("1:a");
expect(ref).toEqual({ line: 1, hash: "a" });
});
test("parses long hash", () => {
const ref = parseLineRef("100:abcdef0123456789");
expect(ref).toEqual({ line: 100, hash: "abcdef0123456789" });
});
test("rejects missing colon", () => {
expect(() => parseLineRef("5abcd")).toThrow(/Invalid line reference/);
});
test("rejects non-numeric line", () => {
expect(() => parseLineRef("abc:1234")).toThrow(/Invalid line reference/);
});
test("rejects non-alphanumeric hash", () => {
expect(() => parseLineRef("5:$$$$")).toThrow(/Invalid line reference/);
});
test("rejects line number 0", () => {
expect(() => parseLineRef("0:abcd")).toThrow(/Line number must be >= 1/);
});
test("rejects empty string", () => {
expect(() => parseLineRef("")).toThrow(/Invalid line reference/);
});
test("rejects empty hash", () => {
expect(() => parseLineRef("5:")).toThrow(/Invalid line reference/);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// validateLineRef
// ═══════════════════════════════════════════════════════════════════════════
describe("validateLineRef", () => {
test("accepts valid ref with matching hash", () => {
const lines = ["hello", "world"];
const hash = computeLineHash(1, "hello");
expect(() => validateLineRef({ line: 1, hash }, lines)).not.toThrow();
});
test("rejects line out of range (too high)", () => {
const lines = ["hello"];
const hash = computeLineHash(1, "hello");
expect(() => validateLineRef({ line: 2, hash }, lines)).toThrow(/does not exist/);
});
test("rejects line out of range (zero)", () => {
const lines = ["hello"];
expect(() => validateLineRef({ line: 0, hash: "aaaa" }, lines)).toThrow(/does not exist/);
});
test("rejects mismatched hash", () => {
const lines = ["hello", "world"];
expect(() => validateLineRef({ line: 1, hash: "0000" }, lines)).toThrow(/has changed since last read/);
});
test("validates last line correctly", () => {
const lines = ["a", "b", "c"];
const hash = computeLineHash(3, "c");
expect(() => validateLineRef({ line: 3, hash }, lines)).not.toThrow();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// applyHashlineEdits — replace
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — replace", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
}
test("replaces single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("range replace (shrink)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "ONE" } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nONE\nddd");
});
test("range replace (same count)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "XXX\nYYY" } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nXXX\nYYY\nddd");
expect(result.firstChangedLine).toBe(2);
});
test("replaces first line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(1, "first"), new_text: "FIRST" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("FIRST\nsecond\nthird");
expect(result.firstChangedLine).toBe(1);
});
test("replaces last line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(3, "third"), new_text: "THIRD" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("first\nsecond\nTHIRD");
expect(result.firstChangedLine).toBe(3);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// applyHashlineEdits — delete
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — delete", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
}
test("deletes single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(2, "bbb"), new_text: "" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("deletes range of lines", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "" } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nddd");
});
test("deletes first line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(1, "aaa"), new_text: "" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("bbb\nccc");
});
test("deletes last line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(3, "ccc"), new_text: "" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// applyHashlineEdits — insert
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — insert", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
}
test("inserts after a line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "NEW" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("inserts multiple lines", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "x\ny\nz" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nx\ny\nz\nbbb");
});
test("inserts after last line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(2, "bbb"), text: "NEW" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nNEW");
});
test("insert with empty dst throws", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "" } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// applyHashlineEdits — heuristics
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — heuristics", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
}
test("strips insert-after anchor echo", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(2, "bbb"), text: "bbb\nNEW" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nNEW\nccc");
});
test("strips range boundary echo and preserves whitespace on unchanged lines", () => {
const content = [
"import { foo } from 'x';",
"if (cond) {",
" doA();",
"} else {",
" doB();",
"}",
"after();",
].join("\n");
const start = 2;
const end = 6;
const edits: HashlineEdit[] = [
{
replace_lines: {
start_anchor: makeRef(start, "if (cond) {"),
end_anchor: makeRef(end, "}"),
// Echoes line after the range ("after();") and also reformats the import line.
new_text: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"].join("\n"),
},
},
];
const result = applyHashlineEdits(content, edits);
// Should not duplicate the trailing boundary line.
expect(result.content.split("\n")).toHaveLength(7);
expect(result.content).toBe(content);
});
test("does not override model whitespace choices in replacement content", () => {
const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n");
const edits: HashlineEdit[] = [
{
replace_lines: {
start_anchor: makeRef(1, "import { foo } from 'x';"),
end_anchor: makeRef(2, "import { bar } from 'y';"),
new_text: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"].join("\n"),
},
},
];
const result = applyHashlineEdits(content, edits);
const outLines = result.content.split("\n");
// Model's whitespace choice is respected -- no longer overridden
expect(outLines[0]).toBe("import {foo} from 'x';");
expect(outLines[1]).toBe("import { bar } from 'y';");
expect(outLines[2]).toBe("// added");
expect(outLines[3]).toBe("const x = 1;");
});
test("restores a long wrapped line when model reflows it across many lines", () => {
const longLine =
"const options = veryLongIdentifier + anotherLongIdentifier + thirdLongIdentifier + fourthLongIdentifier;";
const content = ["before();", longLine, "after();"].join("\n");
const edits: HashlineEdit[] = [
{
set_line: {
anchor: makeRef(2, longLine),
new_text: [
"const",
"options",
"=",
"veryLongIdentifier",
"+",
"anotherLongIdentifier",
"+",
"thirdLongIdentifier",
"+",
"fourthLongIdentifier;",
].join("\n"),
},
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe(content);
});
test("repairs single-line replacement that absorbed the next line (prevents duplication)", () => {
const content = [" typeof HOOK === 'undefined' &&", " typeof HOOK.checkDCE !== 'function'", "tail();"].join(
"\n",
);
const edits: HashlineEdit[] = [
{
set_line: {
anchor: makeRef(1, " typeof HOOK === 'undefined' &&"),
// Model merged both lines into one and dropped indentation.
new_text: "typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'",
},
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe(
[" typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'", "tail();"].join("\n"),
);
});
test("repairs single-line replacement that absorbed the previous line (prevents duplication)", () => {
const content = [
" const nativeStyleResolver: ResolveNativeStyle | void =",
" resolveRNStyle || hook.resolveRNStyle;",
" after();",
].join("\n");
const edits: HashlineEdit[] = [
{
set_line: {
anchor: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"),
// Model absorbed the declaration line and dropped indentation.
new_text:
"const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;",
},
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe(
[
" const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;",
" after();",
].join("\n"),
);
});
test("accepts polluted src that starts with LINE:HASH but includes trailing content", () => {
const content = "aaa\nbbb\nccc";
const srcHash = computeLineHash(2, "bbb");
const edits: HashlineEdit[] = [
{
set_line: {
anchor: `2:${srcHash}export function foo(a, b) {}`, // comma in trailing content
new_text: "BBB",
},
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
});
test("treats same-line ranges as single-line replacements", () => {
const content = "aaa\nbbb\nccc";
const good = makeRef(2, "bbb");
const edits: HashlineEdit[] = [{ replace_lines: { start_anchor: good, end_anchor: good, new_text: "BBB" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
});
test("normalizes unicode-confusable hyphens when an edit would otherwise be a no-op", () => {
const content = "aaa\ndevtools–unsupported-bridge-protocol\nccc";
// dst is byte-identical to original (en-dash), so this would normally be a no-op.
const edits: HashlineEdit[] = [
{
set_line: {
anchor: makeRef(2, "devtools–unsupported-bridge-protocol"),
new_text: "devtools–unsupported-bridge-protocol",
},
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\ndevtools-unsupported-bridge-protocol\nccc");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// applyHashlineEdits — multiple edits
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — multiple edits", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
}
test("applies two non-overlapping replaces (bottom-up safe)", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [
{ set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } },
{ set_line: { anchor: makeRef(4, "ddd"), new_text: "DDD" } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc\nDDD\neee");
expect(result.firstChangedLine).toBe(2);
});
test("applies replace + delete in one call", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } },
{ set_line: { anchor: makeRef(4, "ddd"), new_text: "" } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
});
test("applies replace + insert in one call", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ set_line: { anchor: makeRef(3, "ccc"), new_text: "CCC" } },
{ insert_after: { anchor: makeRef(1, "aaa"), text: "INSERTED" } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nINSERTED\nbbb\nCCC");
});
test("applies non-overlapping edits against original anchors when line counts change", () => {
const content = "one\ntwo\nthree\nfour\nfive\nsix";
const edits: HashlineEdit[] = [
{
replace_lines: {
start_anchor: makeRef(2, "two"),
end_anchor: makeRef(3, "three"),
new_text: "TWO_THREE",
},
},
{ set_line: { anchor: makeRef(6, "six"), new_text: "SIX" } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("one\nTWO_THREE\nfour\nfive\nSIX");
});
test("empty edits array is a no-op", () => {
const content = "aaa\nbbb";
const result = applyHashlineEdits(content, []);
expect(result.content).toBe(content);
expect(result.firstChangedLine).toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// applyHashlineEdits — error cases
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — errors", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
}
test("rejects stale hash", () => {
const content = "aaa\nbbb\nccc";
// Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [{ set_line: { anchor: "2:zz", new_text: "BBB" } }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
test("stale hash error shows >>> markers with correct hashes", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [{ set_line: { anchor: "2:zz", new_text: "BBB" } }];
try {
applyHashlineEdits(content, edits);
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(HashlineMismatchError);
const msg = (err as HashlineMismatchError).message;
// Should contain >>> marker on the mismatched line
expect(msg).toContain(">>>");
// Should show the correct hash for line 2
const correctHash = computeLineHash(2, "bbb");
expect(msg).toContain(`2:${correctHash}|bbb`);
// Context lines should NOT have >>> markers
const lines = msg.split("\n");
const contextLines = lines.filter(l => l.startsWith(" ") && !l.startsWith(" ...") && l.includes(":"));
expect(contextLines.length).toBeGreaterThan(0);
}
});
test("stale hash error collects all mismatches", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
// Use hashes that don't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [
{ set_line: { anchor: "2:zz", new_text: "BBB" } },
{ set_line: { anchor: "4:zz", new_text: "DDD" } },
];
try {
applyHashlineEdits(content, edits);
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(HashlineMismatchError);
const e = err as HashlineMismatchError;
expect(e.mismatches).toHaveLength(2);
expect(e.mismatches[0].line).toBe(2);
expect(e.mismatches[1].line).toBe(4);
// Both lines should have >>> markers
const markerLines = e.message.split("\n").filter(l => l.startsWith(">>>"));
expect(markerLines).toHaveLength(2);
}
});
test("relocates stale line refs when hash uniquely identifies a moved line", () => {
const content = "aaa\nbbb\nccc";
const staleButUnique = `2:${computeLineHash(1, "ccc")}`;
const edits: HashlineEdit[] = [{ set_line: { anchor: staleButUnique, new_text: "CCC" } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nCCC");
});
test("does not relocate when expected hash is non-unique", () => {
const content = "dup\nmid\ndup";
const staleDuplicate = `2:${computeLineHash(1, "dup")}`;
const edits: HashlineEdit[] = [{ set_line: { anchor: staleDuplicate, new_text: "DUP" } }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
test("rejects out-of-range line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ set_line: { anchor: "10:aa", new_text: "X" } }];
expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/);
});
test("rejects range with start > end", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [
{ replace_lines: { start_anchor: makeRef(5, "eee"), end_anchor: makeRef(2, "bbb"), new_text: "X" } },
];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert-after with empty dst", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "" } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects replace edit (handled separately in EditTool)", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ replace: { old_text: "aaa", new_text: "AAA" } }];
expect(() => applyHashlineEdits(content, edits)).toThrow(/replace edits are applied separately/);
});
});