oh-my-opencode/src/tools/hashline-edit/edit-operations.test.ts
YeonGyu-Kim 9a2e0f1add refactor(hashline-edit): remove unnecessary barrel re-exports of internal primitives
applySetLine, applyReplaceLines, applyInsertAfter, applyInsertBefore
were re-exported from both edit-operations.ts and index.ts but have no
external consumers — they are only used internally within the module.
Only applyHashlineEdits (the public API) remains exported.
2026-02-24 15:33:17 +09:00

376 lines
11 KiB
TypeScript

import { describe, expect, it } from "bun:test"
import { applyHashlineEdits } from "./edit-operations"
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
import { computeLineHash } from "./hash-computation"
import type { HashlineEdit } from "./types"
function anchorFor(lines: string[], line: number): string {
return `${line}#${computeLineHash(line, lines[line - 1])}`
}
describe("hashline edit operations", () => {
it("applies set_line with LINE#ID anchor", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "new line 2")
//#then
expect(result).toEqual(["line 1", "new line 2", "line 3"])
})
it("applies replace_lines with LINE#ID anchors", () => {
//#given
const lines = ["line 1", "line 2", "line 3", "line 4"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 2), anchorFor(lines, 3), "replaced")
//#then
expect(result).toEqual(["line 1", "replaced", "line 4"])
})
it("applies insert_after with LINE#ID anchor", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applyInsertAfter(lines, anchorFor(lines, 2), "inserted")
//#then
expect(result).toEqual(["line 1", "line 2", "inserted", "line 3"])
})
it("applies insert_before with LINE#ID anchor", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applyHashlineEdits(
lines.join("\n"),
[{ op: "prepend", pos: anchorFor(lines, 2), lines: "before 2" }]
)
//#then
expect(result).toEqual("line 1\nbefore 2\nline 2\nline 3")
})
it("throws when insert_after receives empty text array", () => {
//#given
const lines = ["line 1", "line 2"]
//#when / #then
expect(() => applyInsertAfter(lines, anchorFor(lines, 1), [])).toThrow(/non-empty/i)
})
it("throws when insert_before receives empty text array", () => {
//#given
const lines = ["line 1", "line 2"]
//#when / #then
expect(() =>
applyHashlineEdits(lines.join("\n"), [{ op: "prepend", pos: anchorFor(lines, 1), lines: [] }])
).toThrow(/non-empty/i)
})
it("applies mixed edits in one pass", () => {
//#given
const content = "line 1\nline 2\nline 3"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
{ op: "replace", pos: anchorFor(lines, 3), lines: "modified" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
})
it("deduplicates identical insert edits in one pass", () => {
//#given
const content = "line 1\nline 2"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\ninserted\nline 2")
})
it("keeps literal backslash-n in plain string text", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "join(\\n)")
//#then
expect(result).toEqual(["line 1", "join(\\n)", "line 3"])
})
it("strips copied hashline prefixes from multiline text", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK|first\n2#NP|second")
//#then
expect(result).toEqual(["line 1", "first", "second", "line 3"])
})
it("autocorrects anchor echo for insert_after payload", () => {
//#given
const lines = ["line 1", "line 2"]
//#when
const result = applyInsertAfter(lines, anchorFor(lines, 1), ["line 1", "inserted"])
//#then
expect(result).toEqual(["line 1", "inserted", "line 2"])
})
it("throws when insert_after payload only repeats anchor line", () => {
//#given
const lines = ["line 1", "line 2"]
//#when / #then
expect(() => applyInsertAfter(lines, anchorFor(lines, 1), ["line 1"])).toThrow(/non-empty/i)
})
it("restores indentation for paired single-line replacement", () => {
//#given
const lines = ["if (x) {", " return 1", "}"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "return 2")
//#then
expect(result).toEqual(["if (x) {", " return 2", "}"])
})
it("preserves intentional indentation removal (tab to no-tab)", () => {
//#given
const lines = ["# Title", "\t1절", "content"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "1절")
//#then
expect(result).toEqual(["# Title", "1절", "content"])
})
it("preserves intentional indentation removal (spaces to no-spaces)", () => {
//#given
const lines = ["function foo() {", " indented", "}"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "indented")
//#then
expect(result).toEqual(["function foo() {", "indented", "}"])
})
it("strips boundary echo around replace_lines content", () => {
//#given
const lines = ["before", "old 1", "old 2", "after"]
//#when
const result = applyReplaceLines(
lines,
anchorFor(lines, 2),
anchorFor(lines, 3),
["before", "new 1", "new 2", "after"]
)
//#then
expect(result).toEqual(["before", "new 1", "new 2", "after"])
})
it("restores indentation for first replace_lines entry", () => {
//#given
const lines = ["if (x) {", " return 1", " return 2", "}"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 2), anchorFor(lines, 3), ["return 3", "return 4"])
//#then
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
})
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
//#given — reproduces the 애국가 bug where blank+indented lines collapse
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
//#when — replace the range with indented version (blank lines preserved)
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
anchorFor(lines, 7),
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
)
//#then — all 7 lines preserved with indentation, not collapsed to 3
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
})
it("collapses wrapped replacement span back to unique original single line", () => {
//#given
const lines = [
"const request = buildRequest({ method: \"GET\", retries: 3 })",
"const done = true",
]
//#when
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
anchorFor(lines, 1),
["const request = buildRequest({", "method: \"GET\", retries: 3 })"]
)
//#then
expect(result).toEqual([
"const request = buildRequest({ method: \"GET\", retries: 3 })",
"const done = true",
])
})
it("keeps wrapped replacement when canonical match is not unique in original lines", () => {
//#given
const lines = ["const query = a + b", "const query = a+b", "const done = true"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["const query = a +", "b"])
//#then
expect(result).toEqual(["const query = a +", "b", "const done = true"])
})
it("keeps wrapped replacement when same canonical candidate appears multiple times", () => {
//#given
const lines = ["const expression = alpha + beta + gamma", "const done = true"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 1), [
"const expression = alpha +",
"beta + gamma",
"const expression = alpha +",
"beta + gamma",
])
//#then
expect(result).toEqual([
"const expression = alpha +",
"beta + gamma",
"const expression = alpha +",
"beta + gamma",
"const done = true",
])
})
it("keeps wrapped replacement when canonical match is shorter than threshold", () => {
//#given
const lines = ["a + b", "const done = true"]
//#when
const result = applyReplaceLines(lines, anchorFor(lines, 1), anchorFor(lines, 1), ["a +", "b"])
//#then
expect(result).toEqual(["a +", "b", "const done = true"])
})
it("applies append and prepend operations", () => {
//#given
const content = "line 1\nline 2"
//#when
const result = applyHashlineEdits(content, [
{ op: "append", lines: ["line 3"] },
{ op: "prepend", lines: ["line 0"] },
])
//#then
expect(result).toEqual("line 0\nline 1\nline 2\nline 3")
})
it("appends to empty file without extra blank line", () => {
//#given
const lines = [""]
//#when
const result = applyAppend(lines, ["line1"])
//#then
expect(result).toEqual(["line1"])
})
it("prepends to empty file without extra blank line", () => {
//#given
const lines = [""]
//#when
const result = applyPrepend(lines, ["line1"])
//#then
expect(result).toEqual(["line1"])
})
it("autocorrects single-line merged replacement into original line count", () => {
//#given
const lines = ["const a = 1;", "const b = 2;"]
//#when
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
anchorFor(lines, 2),
"const a = 10; const b = 20;"
)
//#then
expect(result).toEqual(["const a = 10;", "const b = 20;"])
})
it("throws on overlapping range edits", () => {
//#given
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 3), lines: "replaced A" },
{ op: "replace", pos: anchorFor(lines, 2), end: anchorFor(lines, 4), lines: "replaced B" },
]
//#when / #then
expect(() => applyHashlineEdits(content, edits)).toThrow(/overlapping/i)
})
it("allows non-overlapping range edits", () => {
//#given
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 2), lines: "replaced A" },
{ op: "replace", pos: anchorFor(lines, 4), end: anchorFor(lines, 5), lines: "replaced B" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("replaced A\nline 3\nreplaced B")
})
})