diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/conformance.lua | 21 | ||||
| -rw-r--r-- | tests/invariants.lua | 226 |
2 files changed, 247 insertions, 0 deletions
diff --git a/tests/conformance.lua b/tests/conformance.lua index 2598ab7..b8f37cb 100644 --- a/tests/conformance.lua +++ b/tests/conformance.lua @@ -9,8 +9,11 @@ package.path = table.concat({ local composition_root = require("clever_f.composition_root") local domain = require("clever_f.domain") local sequence_state = require("clever_f.sequence_state") +local text_topology = require("clever_f.text_topology") local MemoryHost = require("clever_f.testing.memory_host") +local invariants = dofile(root .. "/tests/invariants.lua") + local NULL = vim.NIL local function is_null(value) @@ -322,7 +325,25 @@ local function run_step(context, step, step_index) if nullable(step.count) ~= nil then context.host:set_count(step.count) end + local before_state = context.state:snapshot() + local mode = context.host:read_mode() + local origin = context.host:read_cursor() + local before_view = text_topology.from_host(context.host) + local persistent_request_count = #context.feedback:persistent_requests() outcome = invoke_action(context, step.action) + invariants.after_action({ + action = step.action, + outcome = outcome, + host = context.host, + state = context.state, + coordinator = context.facade:coordinator(), + feedback = context.feedback, + before_state = before_state, + before_view = before_view, + persistent_request_count = persistent_request_count, + mode = mode, + origin = origin, + }) elseif operation == "set_cursor" then context.host:set_cursor(position(step.position)) elseif operation == "set_mode" then diff --git a/tests/invariants.lua b/tests/invariants.lua new file mode 100644 index 0000000..1c17b69 --- /dev/null +++ b/tests/invariants.lua @@ -0,0 +1,226 @@ +local M = {} + +local domain = require("clever_f.domain") +local text_topology = require("clever_f.text_topology") + +local function same(expected, actual, message) + if expected ~= actual then + error((message or "invariant failed") + .. ": expected " .. tostring(expected) + .. ", got " .. tostring(actual), 3) + end +end + +local function same_map_values(expected, actual, excluded, message) + for key, value in pairs(expected) do + if key ~= excluded then + same(value, actual[key], message .. " for " .. key.key) + end + end + for key, value in pairs(actual) do + if key ~= excluded then + same(expected[key], value, message .. " for " .. key.key) + end + end +end + +local function resolved_action(options) + if options.action == "RepeatSameDirection" + or options.action == "RepeatOppositeDirection" + then + return options.coordinator:last_explicit_resolution() + end + if options.action == "DotRepeat" then + return nil + end + return options.coordinator:last_primary_resolution() +end + +local function assert_valid_endpoint(options) + local outcome = options.outcome + if outcome.search_outcome == nil or outcome.successful_steps == 0 then + return + end + local view = options.before_view + if not text_topology.TextView.is(view) then + error("landing invariant requires the pre-action TextView", 3) + end + if not view:is_valid_cursor_position(outcome.search_outcome.endpoint) then + error("a reached landing must be a valid byte-start position", 3) + end +end + +local function assert_success_history(options, before, after, context) + local outcome = options.outcome + if outcome.search_outcome == nil then + return + end + local before_landing = before.previous_landing[context] + local after_landing = after.previous_landing[context] + if outcome.complete then + same( + outcome.search_outcome.endpoint, + after_landing, + "a complete move must commit its reached landing" + ) + same(false, after.first_move[context], "a complete move must finish first_move") + if context.visual then + same( + before.moved_forward, + after.moved_forward, + "a Visual move must preserve command direction" + ) + same( + before.moved_forward_initialized, + after.moved_forward_initialized, + "a Visual move must preserve direction initialization" + ) + else + local moved_forward = domain.Position.compare( + outcome.search_outcome.endpoint, + options.origin + ) > 0 + same( + moved_forward, + after.moved_forward, + "a complete command move must commit its direction" + ) + same(true, after.moved_forward_initialized, "command direction must be initialized") + end + else + same( + before_landing, + after_landing, + "an incomplete move must preserve its successful landing" + ) + same( + before.moved_forward, + after.moved_forward, + "an incomplete move must preserve command direction" + ) + same( + before.moved_forward_initialized, + after.moved_forward_initialized, + "an incomplete move must preserve direction initialization" + ) + end +end + +local function assert_context_partition(options, before, after, context) + if options.action == "Reset" or options.action == "DiagnosticFullReset" then + return + end + same_map_values( + before.previous_descriptor, + after.previous_descriptor, + context, + "an action must preserve peer descriptors" + ) + same_map_values( + before.previous_landing, + after.previous_landing, + context, + "an action must preserve peer landings" + ) + same_map_values( + before.first_move, + after.first_move, + context, + "an action must preserve peer first-move values" + ) + same_map_values( + before.previous_target, + after.previous_target, + context, + "an action must preserve peer targets" + ) +end + +local function assert_family(options, after, context, resolution) + if options.outcome.effective_descriptor == nil then + return + end + local initiating = after.previous_descriptor[context] + if initiating == nil then + return + end + same( + initiating.family, + options.outcome.effective_descriptor.family, + "effective motion family must come from the initiating descriptor" + ) + if resolution ~= nil and resolution.motion_plan ~= nil then + same( + initiating.family, + resolution.motion_plan.descriptor.family, + "resolved motion family must come from the initiating descriptor" + ) + end +end + +local function assert_overlay_plans(options, resolution) + if resolution == nil or resolution.target_plan == nil then + return + end + local requests = options.feedback:persistent_requests() + for index = options.persistent_request_count + 1, #requests do + same( + resolution.target_plan, + requests[index].target_plan, + "a new persistent overlay must use the action TargetPlan" + ) + end +end + +local function assert_timer_owner(options, after) + local identity = after.highlight_timer + if identity == nil then + return + end + local timers = options.host:timers() + local timer = timers[identity] + if timer == nil or not timer.active then + error("the current timer identity must name the sole active cleanup timer", 3) + end + local active = 0 + for _, candidate in pairs(timers) do + if candidate.active then + active = active + 1 + end + end + same(1, active, "only the current cleanup timer can remain active") +end + +local function assert_temporary_ownership(options, after) + same(0, #after.temporary_overlays, "an action must release temporary overlay ownership") + for _, highlight in pairs(options.host:highlights()) do + if highlight.group == "CleverFCursor" or highlight.group == "CleverFDirect" then + error("an action must release each temporary presentation resource", 3) + end + end +end + +function M.after_action(options) + if type(options) ~= "table" + or not domain.ActionOutcome.is(options.outcome) + or type(options.host) ~= "table" + or type(options.coordinator) ~= "table" + or type(options.feedback) ~= "table" + or type(options.before_state) ~= "table" + then + error("action invariants require a complete observation", 2) + end + local context = domain.ModeContext.from_full_mode(options.mode) + local after = options.state:snapshot() + assert_valid_endpoint(options) + assert_success_history(options, options.before_state, after, context) + assert_context_partition(options, options.before_state, after, context) + local resolution = resolved_action(options) + assert_family(options, after, context, resolution) + assert_overlay_plans(options, resolution) + assert_timer_owner(options, after) + assert_temporary_ownership(options, after) + return true +end + +return M |
