265 lines
9.4 KiB
TypeScript
265 lines
9.4 KiB
TypeScript
import TextBuffer from "../src/text-buffer";
|
||
import { describe, it, expect } from "vitest";
|
||
|
||
describe("TextBuffer – basic editing parity with Rust suite", () => {
|
||
/* ------------------------------------------------------------------ */
|
||
/* insert_char */
|
||
/* ------------------------------------------------------------------ */
|
||
it("insert_char / printable (single line)", () => {
|
||
// (col, char, expectedLine)
|
||
const cases: Array<[number, string, string]> = [
|
||
[0, "x", "xab"],
|
||
[1, "x", "axb"],
|
||
[2, "x", "abx"],
|
||
[1, "あ", "aあb"],
|
||
];
|
||
|
||
for (const [col, ch, want] of cases) {
|
||
const buf = new TextBuffer("ab");
|
||
buf.move("end"); // go to col 2
|
||
while (buf.getCursor()[1] > col) {
|
||
buf.move("left");
|
||
}
|
||
buf.insert(ch);
|
||
expect(buf.getText()).toBe(want);
|
||
expect(buf.getCursor()).toEqual([0, col + 1]);
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* insert_char – newline support */
|
||
/* ------------------------------------------------------------------ */
|
||
it("insert_char with a newline should split the line", () => {
|
||
const buf = new TextBuffer("ab");
|
||
// jump to end of first (and only) line
|
||
buf.move("end");
|
||
// Insert a raw \n character – the Rust implementation splits the line
|
||
buf.insert("\n");
|
||
|
||
// We expect the text to be split into two separate lines
|
||
expect(buf.getLines()).toEqual(["ab", ""]);
|
||
expect(buf.getCursor()).toEqual([1, 0]);
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* insert_str helpers */
|
||
/* ------------------------------------------------------------------ */
|
||
it("insert_str should insert multi‑line strings", () => {
|
||
const initial = ["ab", "cd", "ef"].join("\n");
|
||
const buf = new TextBuffer(initial);
|
||
|
||
// place cursor at (row:0, col:0)
|
||
// No move needed – cursor starts at 0,0
|
||
|
||
buf.insertStr("x\ny");
|
||
|
||
const wantLines = ["x", "yab", "cd", "ef"];
|
||
expect(buf.getLines()).toEqual(wantLines);
|
||
expect(buf.getCursor()).toEqual([1, 1]);
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Undo / Redo */
|
||
/* ------------------------------------------------------------------ */
|
||
it("undo / redo history should revert edits", () => {
|
||
const buf = new TextBuffer("hello");
|
||
buf.move("end");
|
||
buf.insert("!"); // text becomes "hello!"
|
||
|
||
expect(buf.undo()).toBe(true);
|
||
expect(buf.getText()).toBe("hello");
|
||
|
||
expect(buf.redo()).toBe(true);
|
||
expect(buf.getText()).toBe("hello!");
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Selection model */
|
||
/* ------------------------------------------------------------------ */
|
||
it("copy & paste should operate on current selection", () => {
|
||
const buf = new TextBuffer("hello world");
|
||
buf.startSelection();
|
||
// Select the word "hello"
|
||
buf.move("right"); // h
|
||
buf.move("right"); // e
|
||
buf.move("right"); // l
|
||
buf.move("right"); // l
|
||
buf.move("right"); // o
|
||
buf.endSelection();
|
||
buf.copy();
|
||
|
||
// Move to end and paste
|
||
buf.move("end");
|
||
// add one space before pasting copied word
|
||
buf.insert(" ");
|
||
buf.paste();
|
||
|
||
expect(buf.getText()).toBe("hello world hello");
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Backspace behaviour */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
describe("backspace", () => {
|
||
it("deletes the character to the *left* of the caret within a line", () => {
|
||
const buf = new TextBuffer("abc");
|
||
|
||
// Move caret after the second character ( index 2 => after 'b' )
|
||
buf.move("right"); // -> a|bc (col 1)
|
||
buf.move("right"); // -> ab|c (col 2)
|
||
|
||
buf.backspace();
|
||
|
||
expect(buf.getLines()).toEqual(["ac"]);
|
||
expect(buf.getCursor()).toEqual([0, 1]);
|
||
});
|
||
|
||
it("merges with the previous line when invoked at column 0", () => {
|
||
const buf = new TextBuffer(["ab", "cd"].join("\n"));
|
||
|
||
// Place caret at the beginning of second line
|
||
buf.move("down"); // row = 1, col = 0
|
||
|
||
buf.backspace();
|
||
|
||
expect(buf.getLines()).toEqual(["abcd"]);
|
||
expect(buf.getCursor()).toEqual([0, 2]); // after 'b'
|
||
});
|
||
|
||
it("is a no‑op at the very beginning of the buffer", () => {
|
||
const buf = new TextBuffer("ab");
|
||
buf.backspace(); // caret starts at (0,0)
|
||
|
||
expect(buf.getLines()).toEqual(["ab"]);
|
||
expect(buf.getCursor()).toEqual([0, 0]);
|
||
});
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Vertical cursor movement – we should preserve the preferred column */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
describe("up / down navigation keeps the preferred column", () => {
|
||
it("restores horizontal position when moving across shorter lines", () => {
|
||
// Three lines: long / short / long
|
||
const lines = ["abcdef", "x", "abcdefg"].join("\n");
|
||
const buf = new TextBuffer(lines);
|
||
|
||
// Place caret after the 5th char in first line (col = 5)
|
||
buf.move("end"); // col 6 (after 'f')
|
||
buf.move("left"); // col 5 (between 'e' and 'f')
|
||
|
||
// Move down twice – through a short line and back to a long one
|
||
buf.move("down"); // should land on (1, 1) due to clamp
|
||
buf.move("down"); // desired: (2, 5)
|
||
|
||
expect(buf.getCursor()).toEqual([2, 5]);
|
||
});
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Left / Right arrow navigation across Unicode surrogate pairs */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
describe("left / right navigation", () => {
|
||
it("should treat multi‑code‑unit emoji as a single character", () => {
|
||
// '🐶' is a surrogate‑pair (length 2) but one user‑perceived char.
|
||
const buf = new TextBuffer("🐶a");
|
||
|
||
// Move caret once to the right – logically past the emoji.
|
||
buf.move("right");
|
||
|
||
// Insert another printable character
|
||
buf.insert("x");
|
||
|
||
// We expect the emoji to stay intact and the text to be 🐶xa
|
||
expect(buf.getLines()).toEqual(["🐶xa"]);
|
||
// Cursor should be after the inserted char (two visible columns along)
|
||
expect(buf.getCursor()).toEqual([0, 2]);
|
||
});
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* HandleInput – raw DEL bytes should map to backspace */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
it("handleInput should treat \x7f input as backspace", () => {
|
||
const buf = new TextBuffer("");
|
||
const vp = { width: 80, height: 25 };
|
||
|
||
// Type "hello" via printable input path
|
||
for (const ch of "hello") {
|
||
buf.handleInput(ch, {}, vp);
|
||
}
|
||
|
||
// Two DEL bytes – terminal's backspace
|
||
buf.handleInput("\x7f", {}, vp);
|
||
buf.handleInput("\x7f", {}, vp);
|
||
|
||
expect(buf.getText()).toBe("hel");
|
||
expect(buf.getCursor()).toEqual([0, 3]);
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* HandleInput – `key.delete` should ALSO behave as backspace */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
it("handleInput should treat key.delete as backspace", () => {
|
||
const buf = new TextBuffer("");
|
||
const vp = { width: 80, height: 25 };
|
||
|
||
for (const ch of "hello") {
|
||
buf.handleInput(ch, {}, vp);
|
||
}
|
||
|
||
// Simulate the Delete (Mac backspace) key three times
|
||
buf.handleInput(undefined, { delete: true }, vp);
|
||
buf.handleInput(undefined, { delete: true }, vp);
|
||
buf.handleInput(undefined, { delete: true }, vp);
|
||
|
||
expect(buf.getText()).toBe("he");
|
||
expect(buf.getCursor()).toEqual([0, 2]);
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Cursor positioning semantics */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
describe("cursor movement & backspace semantics", () => {
|
||
it("typing should leave cursor after the last inserted character", () => {
|
||
const vp = { width: 80, height: 25 };
|
||
const buf = new TextBuffer("");
|
||
|
||
buf.handleInput("h", {}, vp);
|
||
expect(buf.getCursor()).toEqual([0, 1]);
|
||
|
||
for (const ch of "ello") {
|
||
buf.handleInput(ch, {}, vp);
|
||
}
|
||
expect(buf.getCursor()).toEqual([0, 5]); // after 'o'
|
||
});
|
||
|
||
it("arrow‑left moves the caret to *between* characters (highlight next)", () => {
|
||
const vp = { width: 80, height: 25 };
|
||
const buf = new TextBuffer("");
|
||
for (const ch of "bar") {
|
||
buf.handleInput(ch, {}, vp);
|
||
} // cursor at col 3
|
||
|
||
buf.move("left"); // col 2 (right before 'r')
|
||
buf.move("left"); // col 1 (right before 'a')
|
||
|
||
expect(buf.getCursor()).toEqual([0, 1]);
|
||
// Character to the RIGHT of caret should be 'a'
|
||
const charRight = [...buf.getLines()[0]!][buf.getCursor()[1]];
|
||
expect(charRight).toBe("a");
|
||
|
||
// Backspace should delete the char to the *left* (i.e. 'b'), leaving "ar"
|
||
buf.backspace();
|
||
expect(buf.getLines()[0]).toBe("ar");
|
||
expect(buf.getCursor()).toEqual([0, 0]);
|
||
});
|
||
});
|
||
});
|