import { afterEach, beforeEach, 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 { adjustIndentation, computeChunkDiff, computeEditDiff, computeHashlineDiff, DEFAULT_FUZZY_THRESHOLD, findMatch, loadChunkSource, parseChunkEditPath, parseChunkReadPath, } from "@oh-my-pi/pi-coding-agent/edit"; describe("findMatch", () => { describe("exact matching", () => { test("finds exact match", () => { const content = "line1\nline2\nline3"; const target = "line2"; const result = findMatch(content, target, { allowFuzzy: false }); expect(result.match).toBeDefined(); expect(result.match!.confidence).toBe(1); expect(result.match!.startLine).toBe(2); }); test("reports multiple occurrences", () => { const content = "foo\nbar\nfoo"; const target = "foo"; const result = findMatch(content, target, { allowFuzzy: false }); expect(result.match).toBeUndefined(); expect(result.occurrences).toBe(2); }); test("returns empty for no match", () => { const content = "line1\nline2"; const target = "notfound"; const result = findMatch(content, target, { allowFuzzy: false }); expect(result.match).toBeUndefined(); expect(result.occurrences).toBeUndefined(); }); }); describe("tab/space normalization", () => { test("matches tabs in file with spaces in target", () => { const content = "\tfoo\n\t\tbar\n\tbaz"; const target = " foo\n bar\n baz"; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeDefined(); expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); }); test("matches spaces in file with tabs in target", () => { const content = " foo\n bar\n baz"; const target = "\tfoo\n\t\tbar\n\tbaz"; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeDefined(); expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); }); test("matches different space counts with same relative structure", () => { const content = " foo\n bar\n baz"; const target = " foo\n bar\n baz"; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeDefined(); expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); }); test("matches single line with different indentation", () => { const content = 'prefix\n\t\t\t"value",\nsuffix'; const target = ' "value",'; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeDefined(); expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); }); }); describe("fallback for inconsistent indentation", () => { test("matches despite one line with wrong indentation in file", () => { const content = "\t\t\tline1\n\t\t\tline2\n\t\tline3\n\t\t\tline4"; const target = " line1\n line2\n line3\n line4"; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeDefined(); expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); }); test("matches when target has consistent indent but file varies", () => { const content = " a\n b\n c\n d"; const target = " a\n b\n c\n d"; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeDefined(); }); }); describe("content matching", () => { test("collapses internal whitespace", () => { const content = "foo bar baz"; const target = "foo bar baz"; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeDefined(); expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); }); test("matches with trailing whitespace differences", () => { const content = "line1 \nline2\t"; const target = "line1\nline2"; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeDefined(); }); }); describe("threshold behavior", () => { test("respects custom similarity threshold", () => { const content = "function foo() {}"; const target = "function bar() {}"; const strictResult = findMatch(content, target, { allowFuzzy: true, threshold: 0.99, }); expect(strictResult.match).toBeUndefined(); const lenientResult = findMatch(content, target, { allowFuzzy: true, threshold: 0.7, }); expect(lenientResult.match).toBeDefined(); }); test("reports fuzzyMatches count when multiple above threshold", () => { const content = " item1\n item2\n item3"; const target = " itemX"; const result = findMatch(content, target, { allowFuzzy: true, threshold: 0.7, }); expect(result.fuzzyMatches).toBeGreaterThan(1); }); }); describe("edge cases", () => { test("handles empty target", () => { const content = "some content"; const result = findMatch(content, "", { allowFuzzy: true }); expect(result).toEqual({}); }); test("handles empty lines in content", () => { const content = "line1\n\nline3"; const target = "line1\n\nline3"; const result = findMatch(content, target, { allowFuzzy: false }); expect(result.match).toBeDefined(); expect(result.match!.confidence).toBe(1); }); test("handles target longer than content", () => { const content = "short"; const target = "this is much longer than the content"; const result = findMatch(content, target, { allowFuzzy: true }); expect(result.match).toBeUndefined(); }); }); }); describe("computeChunkDiff", () => { let tmpDir: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compute-chunk-")); }); afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); test("returns { error } when chunk selector cannot resolve", async () => { const file = path.join(tmpDir, "c.ts"); await fs.writeFile(file, "export const x = 1;\n"); const result = await computeChunkDiff( { path: "c.ts:fn_does_not_exist#ABCD", edits: [ { path: "c.ts:fn_does_not_exist#ABCD", write: "console.log('replaced')\n", }, ], }, tmpDir, ); expect("error" in result).toBe(true); }); test("returns { error } when path is empty", async () => { const result = await computeChunkDiff({ path: "", edits: [{ path: "", write: "x\n" }] }, tmpDir); expect("error" in result).toBe(true); }); test("rejects write:null instead of previewing a delete", async () => { const file = path.join(tmpDir, "null-delete.ts"); await fs.writeFile(file, "export const x = 1;\n"); const result = await computeChunkDiff( { path: "null-delete.ts", edits: [{ path: "null-delete.ts", write: null }], }, tmpDir, ); expect("error" in result).toBe(true); if ("error" in result) { expect(result.error).toContain("write:null no longer deletes chunks"); } }); test("rejects bare chunk edit entries instead of treating them as deletes", async () => { const file = path.join(tmpDir, "bare.ts"); await fs.writeFile(file, "export const x = 1;\n"); const result = await computeChunkDiff( { path: "bare.ts", edits: [{ path: "bare.ts" }], }, tmpDir, ); expect("error" in result).toBe(true); if ("error" in result) { expect(result.error).toContain("no operation specified"); } }); test("rejects write empty string instead of previewing a destructive empty replacement", async () => { const file = path.join(tmpDir, "empty-write.ts"); await fs.writeFile(file, "export const x = 1;\n"); const result = await computeChunkDiff( { path: "empty-write.ts", edits: [{ path: "empty-write.ts", write: "" }], }, tmpDir, ); expect("error" in result).toBe(true); if ("error" in result) { expect(result.error).toContain('write:"" is a destructive empty replacement'); } }); test("aborts when signal fires before compute completes", async () => { const controller = new AbortController(); controller.abort(); const result = await computeChunkDiff( { path: "d.ts", edits: [{ path: "d.ts", write: "foo\n" }], }, tmpDir, { signal: controller.signal }, ); expect("error" in result).toBe(true); }); test("computes diff for a root chunk replacement with valid checksum", async () => { const file = path.join(tmpDir, "e.ts"); await fs.writeFile(file, "export const x = 1;\n"); // Read the file once via loadChunkSource so the test does not depend on // knowing the internal chunk checksum scheme. const loaded = await loadChunkSource({ cwd: tmpDir, path: "e.ts" }); expect(loaded.exists).toBe(true); expect(loaded.rawContent).toContain("export const x"); }); }); describe("chunk path parsing", () => { test("splits local plan URLs with chunk selectors after the URL path", () => { expect(parseChunkEditPath("local://PLAN.md:sct_0_T#SRJJ")).toEqual({ filePath: "local://PLAN.md", selector: "sct_0_T#SRJJ", }); expect(parseChunkReadPath("local://PLAN.md:sct_6_R.sct_6_u#MZKS")).toEqual({ filePath: "local://PLAN.md", selector: "sct_6_R.sct_6_u#MZKS", }); }); test("does not treat the local URL scheme colon as a chunk selector separator", () => { expect(parseChunkEditPath("local://PLAN.md")).toEqual({ filePath: "local://PLAN.md" }); }); }); describe("adjustIndentation", () => { test("adds indentation when actualText is more indented than oldText", () => { const oldText = "foo\nbar"; const actualText = " foo\n bar"; const newText = "foo\nbaz\nbar"; const result = adjustIndentation(oldText, actualText, newText); expect(result).toBe(" foo\n baz\n bar"); }); test("removes indentation when actualText is less indented", () => { const oldText = " foo\n bar"; const actualText = " foo\n bar"; const newText = " foo\n baz"; const result = adjustIndentation(oldText, actualText, newText); expect(result).toBe(" foo\n baz"); }); test("preserves empty lines", () => { const oldText = "foo\n\nbar"; const actualText = " foo\n\n bar"; const newText = "foo\n\nbaz"; const result = adjustIndentation(oldText, actualText, newText); expect(result).toBe(" foo\n\n baz"); }); test("returns unchanged when indentation matches", () => { const oldText = " foo"; const actualText = " foo"; const newText = " bar"; const result = adjustIndentation(oldText, actualText, newText); expect(result).toBe(" bar"); }); test("uses tab from actualText when adding indentation", () => { const oldText = "foo"; const actualText = "\t\tfoo"; const newText = "bar"; const result = adjustIndentation(oldText, actualText, newText); expect(result).toBe("\t\tbar"); }); test("handles mixed content with different indent levels", () => { const oldText = "if (x) {\n return y;\n}"; const actualText = " if (x) {\n return y;\n }"; const newText = "if (x) {\n return z;\n}"; const result = adjustIndentation(oldText, actualText, newText); expect(result).toBe(" if (x) {\n return z;\n }"); }); test("does not go negative on removal", () => { const oldText = " foo"; const actualText = "foo"; const newText = " bar"; const result = adjustIndentation(oldText, actualText, newText); // Should remove up to 4 chars, but line only has 2, so remove 2 expect(result).toBe("bar"); }); }); describe("computeHashlineDiff", () => { let tempDir = ""; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-diff-hashline-")); }); afterEach(async () => { if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); } }); test("returns no-op error for unchanged content when move is absent", async () => { const sourcePath = path.join(tempDir, "source.txt"); await Bun.write(sourcePath, "unchanged content\n"); const result = await computeHashlineDiff({ path: sourcePath, edits: [] }, tempDir); expect("error" in result).toBe(true); if ("error" in result) { expect(result.error).toContain("No changes would be made"); } }); test("accepts hashline tool edits without resolved op/lines", async () => { const sourcePath = path.join(tempDir, "source.txt"); await Bun.write(sourcePath, "first\n"); const result = await computeHashlineDiff( { path: sourcePath, edits: [{ path: sourcePath, loc: "append", content: "second" }] }, tempDir, ); expect("diff" in result).toBe(true); if ("diff" in result) { expect(result.diff).toContain("second"); } }); test("allows move-only operation when content is unchanged", async () => { const sourcePath = path.join(tempDir, "source.txt"); await Bun.write(sourcePath, "unchanged content\n"); const result = await computeHashlineDiff( { path: sourcePath, edits: [], move: path.join(tempDir, "moved", "target.txt") }, tempDir, ); expect("error" in result).toBe(false); if ("diff" in result) { expect(result.diff).toBe(""); expect(result.firstChangedLine).toBeUndefined(); } }); test("returns a handled error when the source path is a local URL", async () => { const result = await computeHashlineDiff({ path: "local://PLAN.md", edits: [] }, tempDir); expect("error" in result).toBe(true); if ("error" in result) { expect(result.error).toContain('internal scheme "local://"'); } }); }); describe("computeEditDiff", () => { let tempDir = ""; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-diff-edit-")); }); afterEach(async () => { if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); } }); test("returns a handled error when the source path is a local URL", async () => { const result = await computeEditDiff("local:/PLAN.md", "old", "new", tempDir); expect("error" in result).toBe(true); if ("error" in result) { expect(result.error).toContain('internal scheme "local://"'); } }); });