diff options
| author | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-04 17:44:33 +0200 |
|---|---|---|
| committer | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-04 17:44:33 +0200 |
| commit | 3ef0616d516d8c8059380566a29852474d95e801 (patch) | |
| tree | 3070e8a04b30ce6204dad7a8f892a8f64735b528 /tests/conformance.lua | |
| parent | 8830e997d00dac5f94c637e8513cfa0ff70f617c (diff) | |
Execute all conformance ledger fixtures
Diffstat (limited to 'tests/conformance.lua')
| -rw-r--r-- | tests/conformance.lua | 806 |
1 files changed, 806 insertions, 0 deletions
diff --git a/tests/conformance.lua b/tests/conformance.lua new file mode 100644 index 0000000..2598ab7 --- /dev/null +++ b/tests/conformance.lua @@ -0,0 +1,806 @@ +local script = debug.getinfo(1, "S").source:sub(2) +local root = script:match("^(.*)/tests/conformance%.lua$") or "." +package.path = table.concat({ + root .. "/lua/?.lua", + root .. "/lua/?/init.lua", + package.path, +}, ";") + +local composition_root = require("clever_f.composition_root") +local domain = require("clever_f.domain") +local sequence_state = require("clever_f.sequence_state") +local MemoryHost = require("clever_f.testing.memory_host") + +local NULL = vim.NIL + +local function is_null(value) + return value == NULL +end + +local function nullable(value) + if is_null(value) then + return nil + end + return value +end + +local function read_file(path) + local file, open_error = io.open(path, "rb") + if file == nil then + error(open_error, 0) + end + local text = file:read("*a") + file:close() + return text +end + +local function decode(path) + return vim.json.decode(read_file(path), { luanil = { object = true, array = true } }) +end + +local function fail(path, expected, actual) + error(path .. ": expected " .. vim.inspect(expected) .. ", got " .. vim.inspect(actual), 0) +end + +local function deep_same(expected, actual, path, partial) + path = path or "value" + if is_null(expected) then + expected = nil + end + if is_null(actual) then + actual = nil + end + if type(expected) ~= "table" then + if expected ~= actual then + fail(path, expected, actual) + end + return + end + if type(actual) ~= "table" then + if partial and next(expected) == nil then + return + end + fail(path, expected, actual) + end + + local expected_count = 0 + for key, value in pairs(expected) do + expected_count = expected_count + 1 + deep_same(value, actual[key], path .. "." .. tostring(key), partial) + end + if not partial then + local actual_count = 0 + for _ in pairs(actual) do + actual_count = actual_count + 1 + end + if expected_count ~= actual_count then + fail(path .. " key count", expected_count, actual_count) + end + end +end + +local function position(value) + value = nullable(value) + return value and domain.Position.new(value.line, value.byte_column) or nil +end + +local function selection(value) + return { + active = value.active, + kind = value.kind, + anchor = position(value.anchor), + focus = position(value.focus), + option = value.option, + } +end + +local function target(value) + value = nullable(value) + if value == nil then + return nil + end + return domain.TargetValue.from_table({ + kind = value.kind, + value = value.value, + first_code = value.first_code, + }) +end + +local function sorted_keys(values) + local keys = {} + for key in pairs(values) do + keys[#keys + 1] = key + end + table.sort(keys) + return keys +end + +local function resource_metadata(scenario) + local result = {} + for _, item in ipairs(scenario.expect.overlay_plans) do + if item.identity ~= nil then + result[item.identity] = result[item.identity] or {} + local metadata = result[item.identity] + for _, key in ipairs({ "group", "window", "anchor_line", "priority" }) do + if item[key] ~= nil then + metadata[key] = item[key] + end + end + if item.positions ~= nil then + metadata.positions = item.positions + end + end + end + return result +end + +local function load_state(state, fixture_state, metadata) + sequence_state._mutate(state, function(data) + data.previous_descriptor = {} + data.previous_landing = {} + data.first_move = {} + data.previous_target = {} + data.known_contexts = {} + data.last_input_context = nil + data.moved_forward = fixture_state.moved_forward + data.moved_forward_initialized = fixture_state.moved_forward_initialized + data.migemo_cache = {} + data.repeat_timestamp_ms = fixture_state.repeat_timestamp_ms + data.highlight_timer = nullable(fixture_state.highlight_timer) + data.target_overlays = {} + data.temporary_overlays = {} + data.finalizers = {} + + for _, key in ipairs(sorted_keys(fixture_state.contexts)) do + local context = domain.ModeContext.from_full_mode(key) + local record = fixture_state.contexts[key] + data.known_contexts[context] = true + local descriptor = nullable(record.previous_descriptor) + data.previous_descriptor[context] = descriptor + and domain.Descriptor.from_string(descriptor) + or nil + data.previous_landing[context] = position(record.previous_landing) + data.first_move[context] = nullable(record.first_move) + data.previous_target[context] = target(record.previous_target) + end + + local input_context = nullable(fixture_state.last_input_context) + data.last_input_context = input_context + and domain.ModeContext.from_full_mode(input_context) + or nil + + for _, encoding in ipairs(fixture_state.migemo_cache) do + data.migemo_cache[encoding] = { fixture_dictionary = encoding } + end + for _, identity in ipairs(fixture_state.target_overlays) do + local item = metadata[identity] or {} + data.target_overlays[#data.target_overlays + 1] = { + identity = identity, + window = item.window or "window-1", + anchor_line = item.anchor_line or 1, + } + end + for _, identity in ipairs(fixture_state.temporary_overlays) do + local item = metadata[identity] or {} + data.temporary_overlays[#data.temporary_overlays + 1] = { + identity = identity, + window = item.window or "window-1", + group = item.group or "CleverFDirect", + } + end + for _, identity in ipairs(fixture_state.finalizers) do + data.finalizers[#data.finalizers + 1] = { + identity = identity, + buffer = "buffer-1", + } + end + end) +end + +local function seed_highlight(host, identity, metadata) + if host:highlights()[identity] ~= nil then + return + end + local item = metadata[identity] or {} + local specification = { + identity = identity, + group = item.group or "CleverFChar", + window = item.window or "window-1", + priority = item.priority or "high", + anchor_line = item.anchor_line or 1, + } + if item.positions ~= nil then + specification.positions = {} + for index, value in ipairs(item.positions) do + specification.positions[index] = position(value) + end + end + host:create_highlight(specification) +end + +local function rename_registration(host, generated, identity) + if generated == identity then + return + end + local registration = host._event_registrations[generated] + host._event_registrations[generated] = nil + registration.identity = identity + host._event_registrations[identity] = registration + for index, value in ipairs(host._event_registration_order) do + if value == generated then + host._event_registration_order[index] = identity + end + end +end + +local function seed_resources(context, fixture_state) + local host = context.host + local feedback = context.feedback + for _, identity in ipairs(fixture_state.target_overlays) do + seed_highlight(host, identity, context.metadata) + end + for _, identity in ipairs(fixture_state.temporary_overlays) do + seed_highlight(host, identity, context.metadata) + end + + local timer_identity = nullable(fixture_state.highlight_timer) + if timer_identity ~= nil and host:timers()[timer_identity] == nil then + local generated + generated = host:start_timer(1, function(callback_identity) + feedback:handle_highlight_timer(callback_identity or generated, "window-1") + end) + if generated ~= timer_identity then + error("fixture timer identity sequence differs: " .. generated .. " and " .. timer_identity, 0) + end + end + + for _, identity in ipairs(fixture_state.finalizers) do + local registrations = host:event_registrations() + if registrations[identity] == nil then + local generated = host:register_events( + { "CursorMoved", "InsertEnter", "TextChanged" }, + function(name, payload) + feedback:handle_finalizer_event(name, payload) + end, + { buffer = "buffer-1" } + ) + rename_registration(host, generated, identity) + end + end +end + +local ACTION_METHODS = { + StartFindForward = "start_find_forward", + StartFindBackward = "start_find_backward", + StartTillForward = "start_till_forward", + StartTillBackward = "start_till_backward", + RepeatSameDirection = "repeat_same_direction", + RepeatOppositeDirection = "repeat_opposite_direction", + Reset = "reset", + DiagnosticFullReset = "diagnostic_full_reset", +} + +local function invoke_action(context, name) + if name == "DotRepeat" then + return context.host:replay_dot(context.host:read_count().value) + end + local method = ACTION_METHODS[name] + if method == nil then + error("unknown fixture action " .. tostring(name), 0) + end + return context.facade[method](context.facade) +end + +local function delete_cursor_character(host) + local cursor = host:read_cursor() + local lines = host:read_text():lines() + local line = lines[cursor.line] + local before = line:sub(1, cursor.byte_column - 1) + local after = line:sub(cursor.byte_column + 1) + lines[cursor.line] = before .. after + host:set_text(lines) + host:set_cursor({ + line = cursor.line, + byte_column = math.max(1, math.min(cursor.byte_column, #lines[cursor.line])), + }) +end + +local function annotate_operations(context, first_index, step_index) + local operations = context.host:operations() + for index = first_index, #operations do + operations[index].after_step = step_index + context.operations[#context.operations + 1] = operations[index] + end +end + +local function run_step(context, step, step_index) + local before = #context.host:operations() + 1 + local operation = step.operation + local outcome + + if operation == "action" then + if nullable(step.count) ~= nil then + context.host:set_count(step.count) + end + outcome = invoke_action(context, step.action) + elseif operation == "set_cursor" then + context.host:set_cursor(position(step.position)) + elseif operation == "set_mode" then + context.host:set_mode(step.mode) + elseif operation == "set_selection" then + context.host:set_selection(selection(step.selection)) + elseif operation == "set_state" then + load_state(context.state, step.state, context.metadata) + seed_resources(context, step.state) + before = #context.host:operations() + 1 + elseif operation == "editor_key" then + if step.key ~= "x" then + error("unknown fixture editor key " .. tostring(step.key), 0) + end + delete_cursor_character(context.host) + elseif operation == "event" then + local event = context.scenario.given.events[step.event_index + 1] + if event.cursor ~= nil then + context.host:set_cursor(position(event.cursor)) + end + if event.buffer_lines ~= nil then + context.host:set_text(event.buffer_lines) + end + local payload = { + buffer = "buffer-1", + window = "window-1", + cursor = event.cursor ~= nil and position(event.cursor) or nil, + } + if event.name == "WinEnter" or event.name == "WinLeave" or event.name == "CmdwinLeave" then + context.feedback:handle_eager_event(event.name, payload) + else + context.host:deliver_event(event.name, payload) + end + elseif operation == "timer_callback" then + local identity = step.timer_identity + local current = context.state.highlight_timer == identity + context.timer_callbacks[#context.timer_callbacks + 1] = { + after_step = step_index, + operation = "fire", + identity = identity, + } + local fired = context.host:fire_timer(identity) + if current then + context.timer_callbacks[#context.timer_callbacks + 1] = { + after_step = step_index, + operation = "set_inactive", + identity = identity, + } + elseif not fired then + context.feedback:handle_highlight_timer(identity, "window-1") + context.timer_callbacks[#context.timer_callbacks + 1] = { + after_step = step_index, + operation = "ignore", + identity = identity, + } + end + else + error("unknown fixture operation " .. tostring(operation), 0) + end + + annotate_operations(context, before, step_index) + if outcome ~= nil then + local item = outcome:to_table() + item.after_step = step_index + item.buffer_lines = context.host:read_text():lines() + context.outcomes[#context.outcomes + 1] = item + end + context.snapshots[step_index] = context.state:to_table() +end + +local function build_context(scenario) + local given = scenario.given + local host_options = { + buffer_lines = given.buffer.lines, + effective_encoding = given.effective_encoding, + cursor = given.cursor, + mode = given.mode, + selection = selection(given.selection), + pending_operator = nullable(given.pending_operator), + count = nullable(given.count), + configuration = given.configuration, + input_packets = given.input_packets, + time_values_ms = given.time_values_ms, + macro_register = nullable(given.host.macro_register), + fold_open_policy = given.host.fold_open_policy, + closed_fold_levels = given.host.closed_fold_levels, + timer_support = given.host.timer_support, + cmdline_cursor_support = given.host.cmdline_cursor_support, + emit_movement_events = false, + } + local host = MemoryHost.new(host_options) + local root_object = composition_root.new({ host = host }) + local state = sequence_state.get() + local context = { + scenario = scenario, + host = host, + facade = root_object:facade(), + feedback = root_object:feedback(), + state = state, + metadata = resource_metadata(scenario), + operations = {}, + outcomes = {}, + snapshots = {}, + timer_callbacks = {}, + identity_aliases = {}, + } + load_state(state, given.state, context.metadata) + seed_resources(context, given.state) + host:clear_operations() + return context +end + +local function operation_list(context, name) + local result = {} + for _, item in ipairs(context.operations) do + if item.operation == name then + result[#result + 1] = item + end + end + return result +end + +local function map_created_identities(context) + local actual = operation_list(context, "create_highlight") + local expected = {} + for _, item in ipairs(context.scenario.expect.overlay_plans) do + if item.operation == "create" or item.operation == "rebuild" then + expected[#expected + 1] = item + end + end + local actual_index = 1 + for _, wanted in ipairs(expected) do + while actual[actual_index] ~= nil + and actual[actual_index].after_step < wanted.after_step + do + actual_index = actual_index + 1 + end + local found = actual[actual_index] + if found == nil or found.after_step ~= wanted.after_step then + error("missing overlay creation for " .. wanted.identity, 0) + end + context.identity_aliases[wanted.identity] = found.identity + actual_index = actual_index + 1 + end + + local registrations = operation_list(context, "register_events") + local registration_index = 1 + for _, wanted in ipairs(context.scenario.expect.host_operations) do + if wanted.operation == "register_finalizer" then + while registrations[registration_index] ~= nil + and registrations[registration_index].after_step < wanted.after_step + do + registration_index = registration_index + 1 + end + local found = registrations[registration_index] + if found == nil or found.after_step ~= wanted.after_step then + error("missing finalizer registration for " .. wanted.identity, 0) + end + context.identity_aliases[wanted.identity] = found.identity + registration_index = registration_index + 1 + end + end +end + +local function alias_identity(context, identity) + return context.identity_aliases[identity] or identity +end + +local function aliased_state(context, expected) + local result = vim.deepcopy(expected) + for _, field in ipairs({ "target_overlays", "temporary_overlays", "finalizers" }) do + if result[field] ~= nil then + for index, identity in ipairs(result[field]) do + result[field][index] = alias_identity(context, identity) + end + end + end + if result.highlight_timer ~= nil and not is_null(result.highlight_timer) then + result.highlight_timer = alias_identity(context, result.highlight_timer) + end + return result +end + +local function target_plan_name(plan) + if not domain.TargetPlan.is(plan) then + return nil + end + return table.concat({ plan.kind.value, plan.case_mode.value, plan.target.value }, "-") +end + +local function actual_overlay_operations(context) + local metadata = {} + for identity, item in pairs(context.metadata) do + metadata[identity] = vim.deepcopy(item) + end + local result = {} + for _, item in ipairs(context.operations) do + if item.operation == "create_highlight" then + metadata[item.identity] = item + local positions = item.positions + if positions == nil and item.position ~= nil then + positions = { item.position } + end + result[#result + 1] = { + after_step = item.after_step, + operation = "create", + group = item.group, + identity = item.identity, + window = item.window, + positions = positions, + priority = item.priority, + anchor_line = item.anchor_line, + descriptor = domain.Descriptor.is(item.descriptor) and item.descriptor.value or item.descriptor, + target_plan = target_plan_name(item.target_plan), + } + elseif item.operation == "remove_highlight" then + local prior = metadata[item.identity] or {} + result[#result + 1] = { + after_step = item.after_step, + operation = "remove", + group = prior.group, + identity = item.identity, + window = prior.window, + } + end + end + return result +end + +local function assert_overlay_plans(context) + local actual = actual_overlay_operations(context) + local used = {} + for _, expected in ipairs(context.scenario.expect.overlay_plans) do + local identity = alias_identity(context, expected.identity) + if expected.operation == "preserve" then + for _, item in ipairs(actual) do + if item.after_step == expected.after_step + and item.identity == identity + and item.operation == "remove" + then + error("preserved overlay was removed: " .. expected.identity, 0) + end + end + else + local wanted_operation = expected.operation == "rebuild" and "create" or expected.operation + local found + local found_index + for index, item in ipairs(actual) do + if not used[index] + and item.after_step == expected.after_step + and item.operation == wanted_operation + and item.identity == identity + then + found = item + found_index = index + break + end + end + if found == nil then + error("missing overlay operation " .. expected.operation .. " for " .. expected.identity, 0) + end + used[found_index] = true + local wanted = vim.deepcopy(expected) + wanted.operation = wanted_operation + wanted.identity = identity + deep_same(wanted, found, "overlay " .. expected.identity, true) + end + end +end + +local function meaningful_host_operations(context) + local result = {} + for _, item in ipairs(context.operations) do + local transformed + if item.operation == "suppress_cursor_presentation" and item.supported then + transformed = { operation = "hide_cursor_presentation" } + elseif item.operation == "restore_cursor_presentation" and item.restored then + transformed = { operation = "restore_cursor_presentation" } + elseif item.operation == "read_input" then + transformed = { operation = "read_input" } + elseif item.operation == "apply_cursor" then + transformed = { operation = "apply_cursor", position = item.position } + elseif item.operation == "apply_selection" then + transformed = { + operation = "apply_selection", + position = item.position, + kind = item.kind, + } + elseif item.operation == "open_fold" and item.opened then + transformed = { operation = "open_fold", fold_level = item.fold_level } + elseif item.operation == "register_events" then + transformed = { operation = "register_finalizer", identity = item.identity } + elseif item.operation == "remove_event_registration" then + transformed = { operation = "remove_finalizer", identity = item.identity } + end + if transformed ~= nil then + transformed.after_step = item.after_step + result[#result + 1] = transformed + end + end + return result +end + +local function assert_expected_operations(expected, actual, context, label) + local used = {} + for _, wanted_source in ipairs(expected) do + local wanted = vim.deepcopy(wanted_source) + if wanted.identity ~= nil then + wanted.identity = alias_identity(context, wanted.identity) + end + local found + for index, candidate in ipairs(actual) do + if not used[index] + and pcall(deep_same, wanted, candidate, label, true) + then + found = candidate + used[index] = true + break + end + end + if found == nil then + error( + "missing expected " .. label .. ": " .. vim.inspect(wanted_source) + .. "\nactual " .. label .. "s: " .. vim.inspect(actual), + 0 + ) + end + end +end + +local function actual_timer_operations(context) + local result = {} + for _, item in ipairs(context.operations) do + if item.operation == "start_timer" and item.supported then + result[#result + 1] = { + after_step = item.after_step, + operation = "start", + identity = item.identity, + delay_ms = item.delay_ms, + } + elseif item.operation == "stop_timer" then + result[#result + 1] = { + after_step = item.after_step, + operation = "stop", + identity = item.identity, + } + end + end + for _, item in ipairs(context.timer_callbacks) do + result[#result + 1] = item + end + table.sort(result, function(left, right) + if left.after_step ~= right.after_step then + return left.after_step < right.after_step + end + local order = { stop = 1, start = 2, fire = 3, set_inactive = 4, ignore = 5 } + return order[left.operation] < order[right.operation] + end) + return result +end + +local function operation_events(context, operation, transform) + local result = {} + for _, item in ipairs(context.operations) do + if item.operation == operation then + result[#result + 1] = transform(item) + end + end + return result +end + +local function assert_scenario(context) + local scenario = context.scenario + map_created_identities(context) + if #scenario.expect.action_outcomes ~= #context.outcomes then + fail("action outcome count", #scenario.expect.action_outcomes, #context.outcomes) + end + deep_same(scenario.expect.action_outcomes, context.outcomes, "action outcomes", true) + + local final_lines = context.host:read_text():lines() + deep_same(scenario.expect.final_buffer, final_lines, "final buffer", false) + deep_same(scenario.expect.final_position, context.host:read_cursor():to_table(), "final position", false) + deep_same(scenario.expect.final_selection, context.host:read_selection():to_table(), "final selection", false) + + for _, expected in ipairs(scenario.expect.state_snapshots) do + local actual = context.snapshots[expected.after_step] + deep_same( + aliased_state(context, expected.state), + actual, + "state after step " .. tostring(expected.after_step), + true + ) + end + + assert_overlay_plans(context) + deep_same(scenario.expect.timer_operations, actual_timer_operations(context), "timer operations", false) + deep_same(scenario.expect.prompts, context.host:prompts(), "prompts", false) + + local redraws = operation_events(context, "redraw", function(item) + return { after_step = item.after_step, kind = item.kind } + end) + deep_same(scenario.expect.redraws, redraws, "redraws", false) + + local diagnostics = operation_events(context, "emit_diagnostic", function(item) + return { after_step = item.after_step, level = item.level, text = item.text } + end) + deep_same(scenario.expect.diagnostics, diagnostics, "diagnostics", false) + + assert_expected_operations( + scenario.expect.host_operations, + meaningful_host_operations(context), + context, + "host operation" + ) +end + +local function run_scenario(scenario) + local context = build_context(scenario) + for index, step in ipairs(scenario.steps) do + run_step(context, step, index - 1) + end + assert_scenario(context) +end + +local function ledger_fixture_ids(path) + local result = {} + local seen = {} + local first = true + for line in read_file(path):gmatch("[^\r\n]+") do + if first then + first = false + else + local columns = vim.split(line, "\t", { plain = true }) + local fixture_ids = columns[8] + if fixture_ids ~= "-" then + for _, identity in ipairs(vim.split(fixture_ids, ",", { plain = true })) do + if not seen[identity] then + seen[identity] = true + result[#result + 1] = identity + end + end + end + end + end + table.sort(result) + return result +end + +local scenarios = {} +for _, filename in ipairs({ "reference-examples.json", "transition-fixtures.json" }) do + local document = decode(root .. "/conformance/fixtures/" .. filename) + if document.format_version ~= 1 then + error(filename .. " has an unsupported format version", 0) + end + for _, scenario in ipairs(document.scenarios) do + if scenarios[scenario.id] ~= nil then + error("duplicate conformance scenario " .. scenario.id, 0) + end + scenarios[scenario.id] = scenario + end +end + +local fixture_ids = ledger_fixture_ids(root .. "/conformance/requirements.tsv") +for _, identity in ipairs(fixture_ids) do + local scenario = scenarios[identity] + if scenario == nil then + error("ledger fixture is missing: " .. identity, 0) + end + local ok, failure = xpcall(function() + run_scenario(scenario) + end, debug.traceback) + if not ok then + io.stderr:write("FAIL: " .. identity .. "\n" .. tostring(failure) .. "\n") + os.exit(1) + end +end + +io.stdout:write(string.format( + "Phase 16 conformance ledger: %d fixtures passed\n", + #fixture_ids +)) |
