summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-03 18:22:01 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-03 21:31:43 +0200
commit66d5e304d5c543a264bffac6d72655c46a6155e1 (patch)
tree186992c5439e9c7e9efa61c6e3d73e88f5f920d7 /tests
parent48a5fb1d753c252780e0a267e1cba37485037fc8 (diff)
Defines motion domain and host capabilities
Diffstat (limited to 'tests')
-rw-r--r--tests/run.lua573
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))