diff options
| author | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-03 22:31:47 +0200 |
|---|---|---|
| committer | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-03 22:31:47 +0200 |
| commit | 6d9e5d7a852929f00b70b3dbb62b2f314f36242c (patch) | |
| tree | 4439e50c66877f70be76bfe4eef155634520b276 /tests/run.lua | |
| parent | 286513b1f9f193262693a122931ee5cbc0e08614 (diff) | |
Implement character topology
Diffstat (limited to 'tests/run.lua')
| -rw-r--r-- | tests/run.lua | 288 |
1 files changed, 287 insertions, 1 deletions
diff --git a/tests/run.lua b/tests/run.lua index 2eb49b3..1cf9053 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11,6 +11,7 @@ local capabilities = require("clever_f.capabilities") local policy = require("clever_f.policy") local sequence_state = require("clever_f.sequence_state") local state_transitions = require("clever_f.state_transitions") +local text_topology = require("clever_f.text_topology") local MemoryHost = require("clever_f.testing.memory_host") local tests = {} @@ -1197,6 +1198,291 @@ test("ClearAllLandingsAndDirection retains movement initialization", function() same(domain.Descriptor.TILL_FORWARD, state:get_previous_descriptor("v")) end) +local function collect_iteration(iterator) + local positions = {} + local characters = {} + local spans = {} + while true do + local position, character, span = iterator() + if position == nil then + break + end + positions[#positions + 1] = position + characters[#characters + 1] = character + spans[#spans + 1] = span + end + return positions, characters, spans +end + +local function position_strings(positions) + local result = {} + for index, position in ipairs(positions) do + result[index] = tostring(position) + end + return result +end + +local MIXED_JAPANESE = "A\227\129\130\239\189\178\230\188\162B" +local MIXED_CHARACTERS = { + "A", + "\227\129\130", + "\239\189\178", + "\230\188\162", + "B", +} + +test("TextView indexes editor characters in each required encoding", function() + local cases = { + { + encoding = "utf-8", + starts = { 1, 2, 5, 8, 11 }, + lengths = { 1, 3, 3, 3, 1 }, + line_length = 11, + }, + { + encoding = "cp932", + starts = { 1, 2, 4, 5, 7 }, + lengths = { 1, 2, 1, 2, 1 }, + line_length = 7, + }, + { + encoding = "euc-jp", + starts = { 1, 2, 4, 6, 8 }, + lengths = { 1, 2, 2, 2, 1 }, + line_length = 8, + }, + } + + for _, case in ipairs(cases) do + local view = text_topology.new({ MIXED_JAPANESE }, case.encoding) + same(case.encoding, view.encoding) + same(1, view.line_count) + same(5, view:line_character_count(1)) + same(case.line_length, view:line_byte_length(1)) + same(case.line_length, #view:line_encoded_text(1)) + + for character_index = 1, #MIXED_CHARACTERS do + local column = view:byte_column_for_character_index(1, character_index) + same(case.starts[character_index], column) + same( + character_index, + view:character_index_for_byte_column(1, column) + ) + same(MIXED_CHARACTERS[character_index], view:character_at(1, column)) + local span = view:byte_span_for_character_index(1, character_index) + same(case.lengths[character_index], span.byte_length) + same(column, span.byte_start) + same(column + span.byte_length - 1, span.byte_end) + truthy(view:is_character_start(span.position)) + end + end + + local ascii = text_topology.new({ "plain" }, "utf8") + same("utf-8", ascii.encoding) + same(5, ascii:line_byte_length(1)) + same(5, ascii:line_character_count(1)) + for index = 1, 5 do + same(index, ascii:character_index_to_byte_column(1, index)) + same(index, ascii:byte_column_to_character_index(1, index)) + end +end) + +test("TextView rejects interior and boundary bytes as character starts", function() + local view = text_topology.new({ MIXED_JAPANESE }, "utf-8") + local valid = { + [1] = true, + [2] = true, + [5] = true, + [8] = true, + [11] = true, + } + + for column = 1, view:line_byte_length(1) do + same(valid[column] == true, view:is_character_start(1, column)) + if not valid[column] then + same(nil, view:try_character_index_for_byte_column(1, column)) + fails(function() + view:character_index_for_byte_column(1, column) + end, "inside an editor character") + end + end + + falsy(view:is_character_start(1, 12)) + fails(function() + view:character_index_for_byte_column(1, 12) + end, "does not identify") + fails(function() + view:byte_column_for_character_index(1, 0) + end, "character_index") +end) + +test("TextView treats Nvim grapheme clusters as editor characters", function() + local combining = "e\204\129x" + local view = text_topology.new({ combining }, "utf-8") + + same(2, view:line_character_count(1)) + same(4, view:line_byte_length(1)) + same(1, view:byte_column_for_character_index(1, 1)) + same(4, view:byte_column_for_character_index(1, 2)) + same("e\204\129", view:character_at_index(1, 1)) + falsy(view:is_character_start(1, 2)) + falsy(view:is_character_start(1, 3)) +end) + +test("Forward and backward iteration enumerate reverse character starts", function() + local view = text_topology.new({ + "a\227\129\130", + "", + "\239\189\178z", + }, "utf-8") + + local forward, characters, spans = collect_iteration(view:iter_buffer_forward()) + list_same({ "(1,1)", "(1,2)", "(3,1)", "(3,4)" }, position_strings(forward)) + list_same({ "a", "\227\129\130", "\239\189\178", "z" }, characters) + for index, position in ipairs(forward) do + truthy(view:is_character_start(position)) + same(position, spans[index].position) + end + + local backward = collect_iteration(view:iter_buffer_backward()) + list_same({ "(3,4)", "(3,1)", "(1,2)", "(1,1)" }, position_strings(backward)) + + local line_forward = collect_iteration(view:iter_line_forward(1)) + local line_backward = collect_iteration(view:iter_line_backward(1)) + list_same({ "(1,1)", "(1,2)" }, position_strings(line_forward)) + list_same({ "(1,2)", "(1,1)" }, position_strings(line_backward)) +end) + +test("Bounded iterators include and stop at their selected boundaries", function() + local view = text_topology.new({ "ab", "cd", "ef" }, "utf-8") + local first = domain.Position.new(1, 2) + local last = domain.Position.new(3, 1) + + local forward = collect_iteration(view:iter_forward(first, last)) + list_same({ "(1,2)", "(2,1)", "(2,2)", "(3,1)" }, position_strings(forward)) + local backward = collect_iteration(view:iter_backward(last, first)) + list_same({ "(3,1)", "(2,2)", "(2,1)", "(1,2)" }, position_strings(backward)) + + fails(function() + view:iter_forward(last, first) + end, "must not follow") + fails(function() + view:iter_backward(first, last) + end, "must not precede") + fails(function() + view:iter_forward(domain.Position.new(1, 3), last) + end, "must start an editor character") +end) + +test("Strict-side iteration obeys line and buffer match-start bounds", function() + local view = text_topology.new({ "abc", "", "def" }, "utf-8") + local origin = domain.Position.new(1, 2) + + local line_forward = collect_iteration(view:iter_strict_forward( + origin, + domain.SearchScope.CURRENT_LINE + )) + list_same({ "(1,3)" }, position_strings(line_forward)) + + local buffer_forward = collect_iteration(view:iter_strict_forward( + origin, + domain.SearchScope.BUFFER + )) + list_same({ "(1,3)", "(3,1)", "(3,2)", "(3,3)" }, position_strings(buffer_forward)) + + local line_backward = collect_iteration(view:iter_strict_backward( + origin, + "current_line" + )) + list_same({ "(1,1)" }, position_strings(line_backward)) + + local buffer_backward = collect_iteration(view:iter_strict_backward( + domain.Position.new(3, 2), + "buffer" + )) + list_same({ "(3,1)", "(1,3)", "(1,2)", "(1,1)" }, position_strings(buffer_backward)) + + local empty_line_bounds = view:line_match_start_bounds(2) + truthy(empty_line_bounds.empty) + same(nil, empty_line_bounds.first) + same(nil, empty_line_bounds.last) + same(0, #position_strings(collect_iteration(view:iter_line_forward(2)))) + same(0, #position_strings(collect_iteration(view:iter_strict_forward( + domain.Position.new(2, 1), + "current_line" + )))) + + local buffer_bounds = view:match_start_bounds("buffer") + falsy(buffer_bounds.empty) + same(domain.Position.new(1, 1), buffer_bounds.first) + same(domain.Position.new(3, 3), buffer_bounds.last) + truthy(buffer_bounds:contains(domain.Position.new(2, 1))) +end) + +test("Endpoint adjacency crosses lines and normalizes cursor boundaries", function() + local view = text_topology.new({ + "a\227\129\130", + "", + "\239\189\178z", + }, "utf-8") + + same(nil, view:predecessor(domain.Position.new(1, 1))) + same(domain.Position.new(1, 1), view:predecessor(domain.Position.new(1, 2))) + same(domain.Position.new(2, 1), view:successor(domain.Position.new(1, 2))) + same(domain.Position.new(1, 2), view:predecessor(domain.Position.new(2, 1))) + same(domain.Position.new(3, 1), view:successor(domain.Position.new(2, 1))) + same(domain.Position.new(2, 1), view:predecessor(domain.Position.new(3, 1))) + same(domain.Position.new(3, 4), view:successor(domain.Position.new(3, 1))) + same(nil, view:successor(domain.Position.new(3, 4))) + + same(domain.Position.new(1, 2), view:normalize_endpoint(1, 3)) + same(domain.Position.new(1, 2), view:normalize_endpoint(1, 5)) + same(domain.Position.new(2, 1), view:normalize_endpoint(2, 7)) + truthy(view:is_valid_cursor_position(view:normalize_endpoint(1, 3))) + truthy(view:is_valid_cursor_position(view:normalize_endpoint(2, 7))) + + fails(function() + view:successor(domain.Position.new(1, 3)) + end, "inside an editor character") + fails(function() + view:predecessor(domain.Position.new(2, 2)) + end, "empty line") +end) + +test("TextView creation reads one operation-local snapshot", function() + local host = MemoryHost.new({ + buffer_lines = { "ab" }, + effective_encoding = "utf-8", + }) + host:clear_operations() + local first = text_topology.from_host(host) + local operations = host:operations() + same(2, #operations) + same("read_text", operations[1].operation) + same("read_encoding", operations[2].operation) + + host:set_text({ "xyz" }) + local second = text_topology.from_host(host) + same("ab", first:line_text(1)) + same(2, first:line_character_count(1)) + same("xyz", second:line_text(1)) + same(3, second:line_character_count(1)) + falsy(first == second) + fails(function() + first.encoding = "cp932" + end, "immutable") +end) + +test("All-empty buffers expose empty full-buffer iteration", function() + local view = text_topology.new({ "", "", "" }, "utf-8") + local bounds = view:buffer_match_start_bounds() + truthy(bounds:is_empty()) + same(nil, bounds.first) + same(nil, bounds.last) + same(0, #position_strings(collect_iteration(view:iter_buffer_forward()))) + same(0, #position_strings(collect_iteration(view:iter_buffer_backward()))) +end) + for _, item in ipairs(tests) do local ok, failure = xpcall(item.body, debug.traceback) if not ok then @@ -1206,4 +1492,4 @@ for _, item in ipairs(tests) do passed = passed + 1 end -io.stdout:write(string.format("Phase 4: %d tests passed\n", passed)) +io.stdout:write(string.format("Phase 5: %d tests passed\n", passed)) |
