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.
376 lines
11 KiB
TypeScript
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")
|
|
})
|
|
})
|