-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: OWL-1.0 or later local testimony = require("testimony") local pt = require("lem.piece_table") local testify = testimony.new("== lem: piece table ==") -- ── creation ────────────────────────────────────────────────────── testify:that("new with content preserves text", function() local p = pt.new("hello world") testimony.assert_equal(11, p:length()) testimony.assert_equal("hello world", p:snapshot()) p:close() end) testify:that("new without content creates empty buffer", function() local p = pt.new() testimony.assert_equal(0, p:length()) testimony.assert_equal("", p:snapshot()) p:close() end) testify:that("new with empty string creates empty buffer", function() local p = pt.new("") testimony.assert_equal(0, p:length()) p:close() end) -- ── insert ──────────────────────────────────────────────────────── testify:that("insert at beginning", function() local p = pt.new("world") p:insert(0, "hello ") testimony.assert_equal("hello world", p:snapshot()) testimony.assert_equal(11, p:length()) p:close() end) testify:that("insert at end", function() local p = pt.new("hello") p:insert(5, " world") testimony.assert_equal("hello world", p:snapshot()) p:close() end) testify:that("insert in middle", function() local p = pt.new("helo") p:insert(2, "l") testimony.assert_equal("hello", p:snapshot()) p:close() end) testify:that("insert into empty buffer", function() local p = pt.new() p:insert(0, "abc") testimony.assert_equal("abc", p:snapshot()) testimony.assert_equal(3, p:length()) p:close() end) testify:that("multiple inserts", function() local p = pt.new() p:insert(0, "c") p:insert(0, "b") p:insert(0, "a") p:insert(3, "d") testimony.assert_equal("abcd", p:snapshot()) p:close() end) testify:that("insert out of range returns error", function() local p = pt.new("abc") local ok, err = p:insert(10, "x") testimony.assert_nil(ok) testimony.assert_not_nil(err) p:close() end) -- ── delete ──────────────────────────────────────────────────────── testify:that("delete from beginning", function() local p = pt.new("hello world") p:delete(0, 6) testimony.assert_equal("world", p:snapshot()) p:close() end) testify:that("delete from end", function() local p = pt.new("hello world") p:delete(5, 6) testimony.assert_equal("hello", p:snapshot()) p:close() end) testify:that("delete from middle", function() local p = pt.new("hello world") p:delete(2, 6) testimony.assert_equal("herld", p:snapshot()) p:close() end) testify:that("delete entire content", function() local p = pt.new("abc") p:delete(0, 3) testimony.assert_equal("", p:snapshot()) testimony.assert_equal(0, p:length()) p:close() end) testify:that("delete out of range returns error", function() local p = pt.new("abc") local ok, err = p:delete(2, 5) testimony.assert_nil(ok) testimony.assert_not_nil(err) p:close() end) testify:that("delete zero length is no-op", function() local p = pt.new("abc") p:delete(1, 0) testimony.assert_equal("abc", p:snapshot()) p:close() end) -- ── get_text ────────────────────────────────────────────────────── testify:that("get_text extracts substring", function() local p = pt.new("hello world") testimony.assert_equal("llo w", p:get_text(2, 5)) p:close() end) testify:that("get_text at beginning", function() local p = pt.new("hello") testimony.assert_equal("hel", p:get_text(0, 3)) p:close() end) testify:that("get_text at end", function() local p = pt.new("hello") testimony.assert_equal("lo", p:get_text(3, 2)) p:close() end) testify:that("get_text clamps to available length", function() local p = pt.new("abc") testimony.assert_equal("bc", p:get_text(1, 100)) p:close() end) testify:that("get_text across insert boundary", function() local p = pt.new("abef") p:insert(2, "cd") testimony.assert_equal("bcde", p:get_text(1, 4)) p:close() end) -- ── line index ──────────────────────────────────────────────────── testify:that("line count for empty buffer is 1", function() local p = pt.new() testimony.assert_equal(1, p:line_count()) p:close() end) testify:that("line count for single line", function() local p = pt.new("hello") testimony.assert_equal(1, p:line_count()) p:close() end) testify:that("line count for two lines", function() local p = pt.new("hello\nworld") testimony.assert_equal(2, p:line_count()) p:close() end) testify:that("line count with trailing newline exposes the empty last line", function() -- A trailing newline creates an empty line after it. LEM counts that -- line so the cursor can sit on it during editing — without this, an -- ENTER at the end of the last line would snap the cursor back. local p = pt.new("hello\nworld\n") testimony.assert_equal(3, p:line_count()) p:close() end) testify:that("line count for multiple lines with trailing newline", function() local p = pt.new("a\nb\nc\nd\n") testimony.assert_equal(5, p:line_count()) p:close() end) testify:that("line_offset returns correct byte offsets", function() local p = pt.new("hello\nworld\nfoo") -- line 1 starts at 0 testimony.assert_equal(0, p:line_offset(1)) -- line 2 starts at 6 (after "hello\n") testimony.assert_equal(6, p:line_offset(2)) -- line 3 starts at 12 (after "world\n") testimony.assert_equal(12, p:line_offset(3)) p:close() end) testify:that("line_length includes newline", function() local p = pt.new("hello\nworld\nfoo") -- "hello\n" = 6 bytes testimony.assert_equal(6, p:line_length(1)) -- "world\n" = 6 bytes testimony.assert_equal(6, p:line_length(2)) -- "foo" = 3 bytes (no trailing newline) testimony.assert_equal(3, p:line_length(3)) p:close() end) testify:that("offset_to_line finds correct line", function() local p = pt.new("hello\nworld\nfoo") -- offset 0 ("h") is line 1 testimony.assert_equal(1, p:offset_to_line(0)) -- offset 5 ("\n") is still line 1 testimony.assert_equal(1, p:offset_to_line(5)) -- offset 6 ("w") is line 2 testimony.assert_equal(2, p:offset_to_line(6)) -- offset 12 ("f") is line 3 testimony.assert_equal(3, p:offset_to_line(12)) p:close() end) testify:that("line index updates after insert", function() local p = pt.new("hello\nworld") testimony.assert_equal(2, p:line_count()) p:insert(5, "\nnew line") testimony.assert_equal(3, p:line_count()) testimony.assert_equal(0, p:line_offset(1)) testimony.assert_equal(6, p:line_offset(2)) p:close() end) testify:that("line index updates after delete", function() local p = pt.new("a\nb\nc") testimony.assert_equal(3, p:line_count()) -- Delete "b\n" (offset 2, length 2) p:delete(2, 2) testimony.assert_equal(2, p:line_count()) testimony.assert_equal("a\nc", p:snapshot()) p:close() end) testify:that("line_offset rejects out-of-range line numbers", function() local p = pt.new("abc") local off = p:line_offset(1) testimony.assert_equal(0, off) -- line 2 doesn't exist, should return nil+error local bad, err = p:line_offset(2) testimony.assert_nil(bad) p:close() end) -- ── undo / redo ─────────────────────────────────────────────────── testify:that("undo reverts insert", function() local p = pt.new("hello") p:insert(5, " world") testimony.assert_equal("hello world", p:snapshot()) testimony.assert_true(p:undo()) testimony.assert_equal("hello", p:snapshot()) p:close() end) testify:that("undo reverts delete", function() local p = pt.new("hello world") p:delete(5, 6) testimony.assert_equal("hello", p:snapshot()) testimony.assert_true(p:undo()) testimony.assert_equal("hello world", p:snapshot()) p:close() end) testify:that("redo restores undone edit", function() local p = pt.new("hello") p:insert(5, " world") p:undo() testimony.assert_equal("hello", p:snapshot()) testimony.assert_true(p:redo()) testimony.assert_equal("hello world", p:snapshot()) p:close() end) testify:that("undo on clean buffer returns false", function() local p = pt.new("hello") testimony.assert_false(p:undo()) p:close() end) testify:that("redo with nothing to redo returns false", function() local p = pt.new("hello") testimony.assert_false(p:redo()) p:close() end) testify:that("new edit after undo clears redo stack", function() local p = pt.new("abc") p:insert(3, "d") testimony.assert_equal("abcd", p:snapshot()) p:undo() testimony.assert_equal("abc", p:snapshot()) p:insert(3, "e") testimony.assert_equal("abce", p:snapshot()) -- redo should fail because we made a new edit testimony.assert_false(p:redo()) p:close() end) testify:that("multiple undos in sequence", function() local p = pt.new("a") p:insert(1, "b") p:insert(2, "c") p:insert(3, "d") testimony.assert_equal("abcd", p:snapshot()) p:undo() testimony.assert_equal("abc", p:snapshot()) p:undo() testimony.assert_equal("ab", p:snapshot()) p:undo() testimony.assert_equal("a", p:snapshot()) testimony.assert_false(p:undo()) p:close() end) -- ── edit grouping ───────────────────────────────────────────────── testify:that("grouped edits undo as one unit", function() local p = pt.new("hello") p:group_open() p:insert(5, "!") p:insert(6, "!") p:insert(7, "!") p:group_close() testimony.assert_equal("hello!!!", p:snapshot()) -- Single undo reverts all three inserts testimony.assert_true(p:undo()) testimony.assert_equal("hello", p:snapshot()) p:close() end) testify:that("group with no edits does not create undo entry", function() local p = pt.new("hello") p:group_open() p:group_close() testimony.assert_false(p:undo()) p:close() end) testify:that("group with mixed insert and delete", function() local p = pt.new("abcd") p:group_open() p:delete(3, 1) -- "abc" p:insert(3, "ef") -- "abcef" p:group_close() testimony.assert_equal("abcef", p:snapshot()) p:undo() testimony.assert_equal("abcd", p:snapshot()) p:close() end) testify:that("nested group_open is ignored", function() local p = pt.new("abc") p:group_open() p:insert(3, "d") p:group_open() -- should be ignored p:insert(4, "e") p:group_close() testimony.assert_equal("abcde", p:snapshot()) p:undo() testimony.assert_equal("abc", p:snapshot()) p:close() end) -- ── UTF-8 content ───────────────────────────────────────────────── testify:that("handles UTF-8 multibyte text", function() local p = pt.new("cafe\xCC\x81") -- "café" with combining accent testimony.assert_equal(6, p:length()) testimony.assert_equal("cafe\xCC\x81", p:snapshot()) p:close() end) testify:that("insert into UTF-8 text", function() local p = pt.new("\xC3\xA9l\xC3\xA8ve") -- "élève" local orig_len = p:length() p:insert(0, "un ") testimony.assert_equal("un \xC3\xA9l\xC3\xA8ve", p:snapshot()) testimony.assert_equal(orig_len + 3, p:length()) p:close() end) -- ── stress / edge cases ─────────────────────────────────────────── testify:that("many small inserts", function() local p = pt.new() for i = 1, 100 do p:insert(p:length(), string.char(96 + (i % 26) + 1)) end testimony.assert_equal(100, p:length()) p:close() end) testify:that("alternating insert and delete", function() local p = pt.new("start") for i = 1, 20 do p:insert(p:length(), "x") p:delete(p:length() - 1, 1) end testimony.assert_equal("start", p:snapshot()) p:close() end) testify:that("undo all edits restores original", function() local p = pt.new("original") p:insert(8, " text") p:delete(0, 3) p:insert(0, "new") p:undo() p:undo() p:undo() testimony.assert_equal("original", p:snapshot()) p:close() end) testify:that("close prevents further operations", function() local p = pt.new("hello") p:close() testimony.assert_error(function() p:length() end, "closed") end) testify:that("gc collects without error", function() local p = pt.new("hello world this is a test") p:insert(5, " beautiful") p = nil collectgarbage("collect") -- If we get here without crashing, the test passes testimony.assert_true(true) end) testify:that("an edit inside an open group clears the redo stack", function() local p = pt.new("a") p:insert(1, "b") -- "ab" p:undo() -- "a", redo available p:group_open() p:insert(1, "c") -- "ac" — new edit must invalidate redo testimony.assert_false(p:redo()) p:group_close() testimony.assert_equal("ac", p:snapshot()) testimony.assert_true(p:undo()) testimony.assert_equal("a", p:snapshot()) p:close() end) testify:that("group_close without group_open is a no-op", function() local p = pt.new("abc") p:group_close() testimony.assert_equal("abc", p:snapshot()) testimony.assert_false(p:undo()) p:close() end) testify:that("negative and fractional offsets are rejected", function() local p = pt.new("abc") testimony.assert_error(function() p:insert(-1, "x") end) testimony.assert_error(function() p:delete(0.5, 1) end) testimony.assert_error(function() p:get_text(0 / 0, 1) end) testimony.assert_equal("abc", p:snapshot()) p:close() end) testify:conclude()