local M = {} local domain = require("clever_tee.domain") local text_topology = require("clever_tee.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 == "CleverTeeCursor" or highlight.group == "CleverTeeDirect" 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