summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tests/conformance.lua21
-rw-r--r--tests/invariants.lua226
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