diff options
| author | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-03 18:22:01 +0200 |
|---|---|---|
| committer | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-03 21:31:43 +0200 |
| commit | 66d5e304d5c543a264bffac6d72655c46a6155e1 (patch) | |
| tree | 186992c5439e9c7e9efa61c6e3d73e88f5f920d7 /tests | |
| parent | 48a5fb1d753c252780e0a267e1cba37485037fc8 (diff) | |
Defines motion domain and host capabilities
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/run.lua | 573 |
1 files changed, 573 insertions, 0 deletions
diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..6a42d00 --- /dev/null +++ b/tests/run.lua @@ -0,0 +1,573 @@ +local script = debug.getinfo(1, "S").source:sub(2) +local root = script:match("^(.*)/tests/run%.lua$") or "." +package.path = table.concat({ + root .. "/lua/?.lua", + root .. "/lua/?/init.lua", + package.path, +}, ";") + +local domain = require("clever_f.domain") +local capabilities = require("clever_f.capabilities") +local MemoryHost = require("clever_f.testing.memory_host") + +local tests = {} +local passed = 0 + +local function test(name, body) + tests[#tests + 1] = { name = name, body = body } +end + +local function same(expected, actual, message) + if expected ~= actual then + error((message or "values differ") + .. ": expected " .. tostring(expected) + .. ", got " .. tostring(actual), 2) + end +end + +local function truthy(value, message) + if not value then + error(message or "value must be true", 2) + end +end + +local function falsy(value, message) + if value then + error(message or "value must be false", 2) + end +end + +local function fails(body, expected_text) + local ok, failure = pcall(body) + if ok then + error("operation must fail", 2) + end + if expected_text and not tostring(failure):find(expected_text, 1, true) then + error("failure does not contain '" .. expected_text .. "': " .. tostring(failure), 2) + end +end + +local function list_same(expected, actual) + same(#expected, #actual, "list lengths differ") + for index = 1, #expected do + same(expected[index], actual[index], "list item " .. tostring(index) .. " differs") + end +end + +test("Position validates one-based byte coordinates", function() + local position = domain.Position.new(1, 1) + same(1, position.line) + same(1, position.byte_column) + same("Position", domain.type_of(position)) + fails(function() + domain.Position.new(0, 1) + end, "line") + fails(function() + domain.Position.new(1, 0) + end, "byte_column") + fails(function() + domain.Position.new(1.5, 1) + end, "line") + fails(function() + position.line = 2 + end, "immutable") +end) + +test("Position comparison is lexicographic and detects stationarity", function() + local first = domain.Position.new(1, 9) + local later_column = domain.Position.new(1, 10) + local later_line = domain.Position.new(2, 1) + local same_place = domain.Position.new(1, 9) + + same(-1, domain.Position.compare(first, later_column)) + same(-1, domain.Position.compare(later_column, later_line)) + same(1, domain.Position.compare(later_line, first)) + truthy(domain.Position.is_forward(later_line, first)) + truthy(domain.Position.is_backward(first, later_line)) + truthy(domain.Position.stationary(first, same_place)) + falsy(domain.Position.is_forward(first, same_place)) + falsy(domain.Position.is_backward(first, same_place)) + truthy(first == same_place) + + local serialized = first:to_table() + same(2, (function() + local count = 0 + for _ in pairs(serialized) do + count = count + 1 + end + return count + end)()) +end) + +test("Descriptor conversion defines all families and directions", function() + local cases = { + { "f", domain.Family.FIND, domain.Direction.FORWARD, false }, + { "F", domain.Family.FIND, domain.Direction.BACKWARD, true }, + { "t", domain.Family.TILL, domain.Direction.FORWARD, false }, + { "T", domain.Family.TILL, domain.Direction.BACKWARD, true }, + } + + for _, case in ipairs(cases) do + local descriptor = domain.Descriptor.from_string(case[1]) + same(case[1], domain.Descriptor.to_string(descriptor)) + same(case[2], descriptor.family) + same(case[3], descriptor.direction) + same(case[4], domain.Descriptor.is_uppercase(descriptor)) + same(descriptor, domain.Descriptor.from_parts(case[2], case[3])) + same(descriptor, domain.Descriptor.swap(domain.Descriptor.swap(descriptor))) + end + + same(domain.Descriptor.FIND_BACKWARD, domain.Descriptor.swap("f")) + same(domain.Descriptor.TILL_FORWARD, domain.Descriptor.swap("T")) + same(domain.Descriptor.FIND_FORWARD, domain.Descriptor.lowercase("F")) + same(domain.Descriptor.TILL_BACKWARD, domain.Descriptor.uppercase("t")) + falsy(domain.Descriptor.is_valid("x")) + fails(function() + domain.Descriptor.from_string("x") + end, "f, F, t, or T") +end) + +test("Count normalizes absent input and rejects nonpositive values", function() + same(domain.Count.ONE, domain.Count.new(nil)) + same(1, domain.Count.to_number(nil)) + same(3, domain.Count.to_number(domain.Count.new(3))) + fails(function() + domain.Count.new(0) + end, "positive") + fails(function() + domain.Count.new(-1) + end, "positive") + fails(function() + domain.Count.new(1.5) + end, "positive") +end) + +test("ModeContext normalizes every operator-pending variant", function() + local operator = domain.ModeContext.from_full_mode("no") + local variants = { + "no", + "nov", + "noV", + "no" .. string.char(0x16), + } + for _, mode in ipairs(variants) do + local context = domain.ModeContext.from_full_mode(mode) + same(operator, context) + same("no", context.key) + truthy(context.operator) + truthy(context.command_path) + end +end) + +test("ModeContext preserves other full strings and exposes mode traits", function() + local normal = domain.ModeContext.from_full_mode("n") + local insert_normal = domain.ModeContext.from_full_mode("niI") + local other_full_mode = domain.ModeContext.from_full_mode("normal-extension") + local visual_character = domain.ModeContext.from_full_mode("v") + local visual_line = domain.ModeContext.from_full_mode("V") + local visual_block = domain.ModeContext.from_full_mode(string.char(0x16)) + local visual_from_select = domain.ModeContext.from_full_mode("vs") + local select_character = domain.ModeContext.from_full_mode("s") + local select_line = domain.ModeContext.from_full_mode("S") + local select_block = domain.ModeContext.from_full_mode(string.char(0x13)) + + same("n", normal.key) + same("niI", insert_normal.key) + same("normal-extension", other_full_mode.key) + falsy(other_full_mode.operator) + falsy(normal == insert_normal) + same(domain.SelectionKind.CHARACTER, visual_character.visual_kind) + same(domain.SelectionKind.LINE, visual_line.visual_kind) + same(domain.SelectionKind.BLOCK, visual_block.visual_kind) + same(domain.SelectionKind.CHARACTER, visual_from_select.visual_kind) + falsy(visual_character.command_path) + same(domain.SelectionKind.CHARACTER, select_character.select_kind) + same(domain.SelectionKind.LINE, select_line.select_kind) + same(domain.SelectionKind.BLOCK, select_block.select_kind) + truthy(select_character.command_path) + falsy(select_character.visual) + truthy(select_character.select) +end) + +test("Selection and text snapshots preserve semantic input values", function() + local anchor = domain.Position.new(1, 1) + local focus = domain.Position.new(1, 3) + local selection = domain.Selection.active( + domain.SelectionKind.CHARACTER, + anchor, + focus, + domain.SelectionOption.EXCLUSIVE + ) + truthy(selection.active) + same(domain.SelectionKind.CHARACTER, selection.kind) + same(domain.SelectionOption.EXCLUSIVE, selection.option) + same(domain.Position.new(1, 4), selection:with_focus(domain.Position.new(1, 4)).focus) + + local inactive = domain.Selection.inactive() + falsy(inactive.active) + same(domain.SelectionKind.NONE, inactive.kind) + + local text = domain.TextSnapshot.new({ "alpha", "beta" }) + same(2, text.line_count) + same("alpha", text:line(1)) + local lines = text:lines() + lines[1] = "changed" + same("alpha", text:line(1)) +end) + +test("TargetValue represents characters, special keys, and code fallback", function() + local character = domain.TargetValue.character("a", 97) + local special = domain.TargetValue.special_key(string.char(0x80, 0xfd, 0x01)) + local fallback = domain.TargetValue.code_fallback() + + same(domain.TargetKind.CHARACTER, character.kind) + same("a", character.value) + same(97, character.first_code) + same(domain.TargetKind.SPECIAL_KEY, special.kind) + same(0x80, special.first_code) + same(domain.TargetKind.CODE_FALLBACK, fallback.kind) + same(0, fallback.first_code) + same("", fallback.value) + fails(function() + domain.TargetValue.special_key("x") + end, "hexadecimal 80") + fails(function() + character.value = "b" + end, "immutable") +end) + +test("InputPacket carries every input capability variant", function() + local text = domain.InputPacket.text("a") + local bytes = domain.InputPacket.raw_bytes({ 0x80, 0xfd, 0x60 }) + local special = domain.InputPacket.special_key("Left", string.char(0x80, 1)) + local failure = domain.InputPacket.error("input failed") + + same(domain.InputPacketKind.TEXT, text.kind) + list_same({ 0x80, 0xfd, 0x60 }, bytes:bytes()) + same("Left", special.name) + same("input failed", failure.message) +end) + +test("Plans and requests carry typed immutable motion data", function() + local target = domain.TargetValue.character("h", 104) + local target_plan = domain.TargetPlan.new({ + target = target, + kind = domain.TargetPlanKind.LITERAL, + case_mode = domain.CaseMode.SENSITIVE, + matcher = function(character) + return character == "h" + end, + }) + truthy(target_plan:matches("h")) + falsy(target_plan:matches("H")) + + local motion_plan = domain.ResolvedMotionPlan.new({ + target_plan = target_plan, + descriptor = "f", + search_scope = domain.SearchScope.BUFFER, + endpoint_policy = domain.EndpointPolicy.REGULAR, + }) + same(domain.Descriptor.FIND_FORWARD, motion_plan.descriptor) + same(target_plan, motion_plan.target_plan) + + local policy = { search_current_line_only = false } + local request = domain.MotionRequest.new({ + context = domain.ModeContext.from_full_mode("n"), + origin = domain.Position.new(1, 1), + descriptor = "f", + target = target, + count = nil, + policy = policy, + first_move = true, + }) + same(1, request.count.value) + truthy(request.first_move) + same(policy, request.policy) + fails(function() + motion_plan.descriptor = domain.Descriptor.FIND_BACKWARD + end, "immutable") +end) + +test("SearchOutcome distinguishes complete, partial, and first-step failure", function() + local origin = domain.Position.new(1, 1) + local reached = domain.Position.new(1, 5) + local complete = domain.SearchOutcome.complete(reached, 3) + local partial = domain.SearchOutcome.boundary_after_partial(reached, 2) + local before = domain.SearchOutcome.boundary_before_any(origin) + + truthy(complete.complete) + same(domain.SearchStatus.COMPLETE, complete.status) + falsy(partial.complete) + same(domain.SearchStatus.BOUNDARY_AFTER_PARTIAL, partial.status) + same(2, partial.successful_steps) + same(origin, before.endpoint) + same(0, before.successful_steps) + fails(function() + domain.SearchOutcome.complete(reached, 0) + end, "successful step") +end) + +test("ActionOutcome and DotPayload retain resolved result data", function() + local target = domain.TargetValue.character("e", 101) + local payload = domain.DotPayload.new("t", target) + local destination = domain.Position.new(1, 4) + local complete = domain.SearchOutcome.complete(destination, 1) + local movement = domain.ActionOutcome.from_search(complete, "t", payload) + + same(domain.ActionKind.MOVEMENT, movement.kind) + same(destination, movement.position) + truthy(movement.complete) + same(1, movement.successful_steps) + same(domain.Descriptor.TILL_FORWARD, movement.effective_descriptor) + same(payload, movement.dot_payload) + same("t", payload:to_table().descriptor) + + local failed = domain.ActionOutcome.from_search( + domain.SearchOutcome.boundary_before_any(destination), + "T" + ) + same(domain.ActionKind.FAILED_SEARCH, failed.kind) + same(false, failed.complete) + same(domain.ActionKind.NEUTRAL, domain.ActionOutcome.neutral(destination).kind) + same(domain.ActionKind.ESCAPE, domain.ActionOutcome.escape(destination).kind) + same(domain.ActionKind.EMPTY, domain.ActionOutcome.empty(destination).kind) + same("problem", domain.ActionOutcome.error(destination, "problem").diagnostic) +end) + +test("Capability contract reports every semantic method", function() + local host = MemoryHost.new() + same(host, capabilities.assert_implements(host)) + same(0, #capabilities.missing_methods(host)) + local required = capabilities.required_methods() + truthy(#required >= 30) + + local incomplete = {} + local missing = capabilities.missing_methods(incomplete) + truthy(#missing == #required) + fails(function() + capabilities.assert_implements(incomplete) + end, "semantic capabilities") +end) + +test("MemoryHost supplies all semantic reads with domain values", function() + local host = MemoryHost.new({ + buffer_lines = { "alpha", "beta" }, + cursor = { line = 2, byte_column = 2 }, + mode = "nov", + selection = { + active = false, + kind = "none", + anchor = nil, + focus = nil, + option = "exclusive", + }, + count = 3, + configuration = { + enabled = false, + triggers = { "x", "y" }, + }, + effective_encoding = "cp932", + macro_register = "q", + fold_open_policy = { "horizontal" }, + closed_fold_levels = 2, + pending_operator = "delete", + time_values_ms = { 10.5, 12 }, + }) + + same("alpha", host:read_text():line(1)) + same(domain.Position.new(2, 2), host:read_cursor()) + same("nov", host:read_mode()) + same("no", host:read_mode_context().key) + same("delete", host:read_pending_operator()) + same(domain.SelectionOption.EXCLUSIVE, host:read_selection().option) + same(3, host:read_count().value) + truthy(host:configuration_present("enabled")) + same(false, host:read_configuration("enabled")) + local triggers = host:read_configuration("triggers") + triggers[1] = "changed" + same("x", host:read_configuration("triggers")[1]) + same("cp932", host:read_encoding()) + truthy(host:read_macro_state().executing) + truthy(host:read_fold_state():opens("horizontal")) + same(2, host:read_fold_state().closed_levels) + same(10.5, host:read_time_ms()) + same(12, host:read_time_ms()) +end) + +test("MemoryHost queues movement events until action state commits", function() + local host = MemoryHost.new({ + buffer_lines = { "abc" }, + cursor = { line = 1, byte_column = 1 }, + }) + local state = { committed = false } + local observations = {} + host:register_events("CursorMoved", function(name, payload) + observations[#observations + 1] = { + name = name, + position = payload.cursor, + committed = state.committed, + } + end) + + local token = host:begin_action_transition() + host:apply_cursor(domain.Position.new(1, 2)) + same(1, host:pending_event_count()) + same(0, #observations) + state.committed = true + host:commit_action_transition(token) + + same(0, host:pending_event_count()) + same(1, #observations) + same("CursorMoved", observations[1].name) + same(domain.Position.new(1, 2), observations[1].position) + truthy(observations[1].committed) +end) + +test("MemoryHost action invocation flushes events after the action callback", function() + local host = MemoryHost.new({ buffer_lines = { "abc" } }) + local state = { landing = nil } + local observed_landing + host:register_events("CursorMoved", function() + observed_landing = state.landing + end) + host:register_action("Move", function() + local destination = domain.Position.new(1, 3) + host:apply_cursor(destination) + state.landing = destination + return "moved" + end) + + same("moved", host:invoke_action("Move")) + same(domain.Position.new(1, 3), observed_landing) +end) + +test("MemoryHost models movement, input, folds, and visible effects", function() + local host = MemoryHost.new({ + buffer_lines = { "abcd" }, + cursor = { line = 1, byte_column = 1 }, + selection = { + active = true, + kind = "character", + anchor = { line = 1, byte_column = 1 }, + focus = { line = 1, byte_column = 1 }, + option = "inclusive", + }, + input_packets = { + { kind = "text", text = "d" }, + { kind = "raw_bytes", bytes = { 0x80, 0xfd, 0x60 } }, + { kind = "error", message = "read failed" }, + }, + fold_open_policy = { "all" }, + closed_fold_levels = 2, + }) + + host:apply_selection(domain.Position.new(1, 2), domain.SelectionKind.CHARACTER) + same(domain.Position.new(1, 2), host:read_cursor()) + same(domain.Position.new(1, 2), host:read_selection().focus) + host:set_operator_inclusive(true) + truthy(host:operator_inclusive()) + + same("d", host:read_input().text) + list_same({ 0x80, 0xfd, 0x60 }, host:read_input():bytes()) + fails(function() + host:read_input() + end, "read failed") + + truthy(host:open_fold()) + truthy(host:open_fold()) + falsy(host:open_fold()) + same(0, host:read_fold_state().closed_levels) + + host:show_prompt("clever-f: ") + host:redraw("screen") + host:redraw("full") + host:emit_diagnostic("error", "problem") + same("clever-f: ", host:prompts()[1]) + list_same({ "screen", "full" }, host:redraws()) + same("problem", host:diagnostics()[1].text) +end) + +test("MemoryHost manages highlight and cursor-presentation resources", function() + local presentation = { + guicursor = "n-v:block", + terminal_cursor_visible = true, + hidden = false, + } + local host = MemoryHost.new({ cursor_presentation = presentation }) + local highlight = host:create_highlight({ + group = "CleverFChar", + identity = "char-1", + position = domain.Position.new(1, 1), + priority = "high", + }) + same("char-1", highlight) + truthy(host:highlights()[highlight] ~= nil) + truthy(host:remove_highlight(highlight)) + falsy(host:remove_highlight(highlight)) + + local lease = host:suppress_cursor_presentation() + truthy(host:cursor_presentation().hidden) + truthy(host:restore_cursor_presentation(lease)) + local restored = host:cursor_presentation() + same(presentation.guicursor, restored.guicursor) + same(presentation.terminal_cursor_visible, restored.terminal_cursor_visible) + same(presentation.hidden, restored.hidden) +end) + +test("MemoryHost manages timers, events, mappings, and dot repeat", function() + local host = MemoryHost.new() + local fired + truthy(host:supports_timers()) + local timer = host:start_timer(25, function(identity) + fired = identity + end) + truthy(host:timers()[timer].active) + truthy(host:fire_timer(timer)) + same(timer, fired) + falsy(host:timers()[timer].active) + falsy(host:stop_timer(timer)) + + local delivered + local registration = host:register_events({ "InsertEnter", "TextChanged" }, function(name) + delivered = name + end, { buffer = "buffer-1" }) + host:deliver_event("InsertEnter", {}) + same("InsertEnter", delivered) + truthy(host:remove_event_registration(registration)) + delivered = nil + host:deliver_event("TextChanged", {}) + same(nil, delivered) + + host:register_action("Neutral", function() + return domain.ActionOutcome.neutral(domain.Position.new(1, 1)) + end) + local mapping = host:register_mapping( + { "n", "x", "o" }, + "f", + "Neutral", + { silent = true, remap = false } + ) + same("f", host:mappings()[mapping].lhs) + truthy(host:mappings()[mapping].options.silent) + + local target = domain.TargetValue.character("a", 97) + local payload = domain.DotPayload.new("f", target) + host:register_dot_repeat(payload, function(replayed, count) + return replayed, count + end) + same(payload, host:dot_repeat_payload()) + local replayed, count = host:replay_dot(2) + same(payload, replayed) + same(2, count.value) +end) + +for _, item in ipairs(tests) do + local ok, failure = xpcall(item.body, debug.traceback) + if not ok then + io.stderr:write("FAIL: " .. item.name .. "\n" .. tostring(failure) .. "\n") + os.exit(1) + end + passed = passed + 1 +end + +io.stdout:write(string.format("Phase 2: %d tests passed\n", passed)) |
