diff options
| -rw-r--r-- | lua/clever_f/sequence_state.lua | 302 | ||||
| -rw-r--r-- | lua/clever_f/state_transitions.lua | 493 | ||||
| -rw-r--r-- | tests/run.lua | 367 |
3 files changed, 1161 insertions, 1 deletions
diff --git a/lua/clever_f/sequence_state.lua b/lua/clever_f/sequence_state.lua new file mode 100644 index 0000000..e31752d --- /dev/null +++ b/lua/clever_f/sequence_state.lua @@ -0,0 +1,302 @@ +local domain = require("clever_f.domain") + +local M = {} +local State = {} +M.State = State + +local MAP_FIELDS = { + "previous_descriptor", + "previous_landing", + "first_move", + "previous_target", +} + +local MAP_FIELD_SET = {} +for _, field in ipairs(MAP_FIELDS) do + MAP_FIELD_SET[field] = true +end + +local data = { + previous_descriptor = {}, + previous_landing = {}, + first_move = {}, + previous_target = {}, + known_contexts = {}, + last_input_context = nil, + moved_forward = false, + moved_forward_initialized = false, + migemo_cache = {}, + repeat_timestamp_ms = 0, + highlight_timer = nil, + target_overlays = {}, + temporary_overlays = {}, + finalizers = {}, +} + +local function fail(message, level) + error(message, (level or 1) + 1) +end + +local function normalize_context(context, name) + if domain.ModeContext.is(context) then + return domain.ModeContext.from_full_mode(context.full_mode) + end + if type(context) ~= "string" or context == "" then + fail((name or "context") .. " must be a ModeContext or full mode string", 2) + end + return domain.ModeContext.from_full_mode(context) +end + +local function copy_map(source) + local result = {} + for key, value in pairs(source) do + result[key] = value + end + return result +end + +local function copy_resource(resource) + local result = {} + for key, value in pairs(resource) do + result[key] = value + end + return result +end + +local function copy_resources(resources) + local result = {} + for index, resource in ipairs(resources) do + result[index] = copy_resource(resource) + end + return result +end + +local function sorted_contexts() + local result = {} + for context in pairs(data.known_contexts) do + result[#result + 1] = context + end + table.sort(result, function(left, right) + return left.key < right.key + end) + return result +end + +local function context_record(context) + return { + context = context, + previous_descriptor = data.previous_descriptor[context], + previous_landing = data.previous_landing[context], + first_move = data.first_move[context], + previous_target = data.previous_target[context], + } +end + +local function context_records() + local result = {} + for _, context in ipairs(sorted_contexts()) do + result[context.key] = context_record(context) + end + return result +end + +function State:get_previous_descriptor(context) + context = normalize_context(context) + return data.previous_descriptor[context] +end + +function State:get_previous_landing(context) + context = normalize_context(context) + return data.previous_landing[context] +end + +function State:get_first_move(context) + context = normalize_context(context) + return data.first_move[context] +end + +function State:get_previous_target(context) + context = normalize_context(context) + return data.previous_target[context] +end + +function State:get_context(context) + context = normalize_context(context) + return context_record(context) +end + +State.context = State.get_context + +function State:has_previous_landing(context) + return self:get_previous_landing(context) ~= nil +end + +function State:get_migemo(encoding) + if type(encoding) ~= "string" or encoding == "" then + fail("encoding must be a nonempty string", 2) + end + return data.migemo_cache[encoding] +end + +function State:target_overlay_identities() + local result = {} + for index, resource in ipairs(data.target_overlays) do + result[index] = resource.identity + end + return result +end + +function State:temporary_overlay_identities() + local result = {} + for index, resource in ipairs(data.temporary_overlays) do + result[index] = resource.identity + end + return result +end + +function State:finalizer_identities() + local result = {} + for index, resource in ipairs(data.finalizers) do + result[index] = resource.identity + end + return result +end + +function State:resources() + return { + highlight_timer = data.highlight_timer, + target_overlays = copy_resources(data.target_overlays), + temporary_overlays = copy_resources(data.temporary_overlays), + finalizers = copy_resources(data.finalizers), + } +end + +function State:snapshot() + local snapshot = { + contexts = context_records(), + last_input_context = data.last_input_context, + moved_forward = data.moved_forward, + moved_forward_initialized = data.moved_forward_initialized, + migemo_cache = copy_map(data.migemo_cache), + repeat_timestamp_ms = data.repeat_timestamp_ms, + highlight_timer = data.highlight_timer, + target_overlays = copy_resources(data.target_overlays), + temporary_overlays = copy_resources(data.temporary_overlays), + finalizers = copy_resources(data.finalizers), + } + for _, field in ipairs(MAP_FIELDS) do + snapshot[field] = copy_map(data[field]) + end + return snapshot +end + +local function target_to_table(target) + return target and target:to_table() or nil +end + +function State:to_table() + local contexts = {} + for _, context in ipairs(sorted_contexts()) do + local record = context_record(context) + contexts[context.key] = { + previous_descriptor = record.previous_descriptor + and record.previous_descriptor.value + or nil, + previous_landing = record.previous_landing + and record.previous_landing:to_table() + or nil, + first_move = record.first_move, + previous_target = target_to_table(record.previous_target), + } + end + + local cache_keys = {} + for encoding in pairs(data.migemo_cache) do + cache_keys[#cache_keys + 1] = encoding + end + table.sort(cache_keys) + + return { + contexts = contexts, + last_input_context = data.last_input_context and data.last_input_context.key or nil, + moved_forward = data.moved_forward, + moved_forward_initialized = data.moved_forward_initialized, + migemo_cache = cache_keys, + repeat_timestamp_ms = data.repeat_timestamp_ms, + highlight_timer = data.highlight_timer, + target_overlays = self:target_overlay_identities(), + temporary_overlays = self:temporary_overlay_identities(), + finalizers = self:finalizer_identities(), + } +end + +local state +local state_metatable = { + __index = function(_, key) + local method = State[key] + if method ~= nil then + return method + end + if MAP_FIELD_SET[key] then + return copy_map(data[key]) + end + if key == "contexts" then + return context_records() + end + if key == "last_input_context" + or key == "moved_forward" + or key == "moved_forward_initialized" + or key == "highlight_timer" + then + return data[key] + end + if key == "repeat_timestamp" or key == "repeat_timestamp_ms" then + return data.repeat_timestamp_ms + end + if key == "migemo_cache" then + return copy_map(data.migemo_cache) + end + if key == "target_overlays" + or key == "temporary_overlays" + or key == "finalizers" + then + return copy_resources(data[key]) + end + return nil + end, + __newindex = function() + fail("SequenceState is read-only; use StateTransitions", 2) + end, + __metatable = "clever_f.sequence_state.State", +} +state = setmetatable({}, state_metatable) + +function M.get() + return state +end + +function M.new() + return state +end + +function M.is(value) + return value == state +end + +M.global = state + +function M._mutate(target, mutation) + if target ~= state then + fail("StateTransitions must use the plugin-global SequenceState", 2) + end + if type(mutation) ~= "function" then + fail("state mutation must be a function", 2) + end + return mutation(data) +end + +function M._normalize_context(context) + return normalize_context(context) +end + +return M diff --git a/lua/clever_f/state_transitions.lua b/lua/clever_f/state_transitions.lua new file mode 100644 index 0000000..426bddb --- /dev/null +++ b/lua/clever_f/state_transitions.lua @@ -0,0 +1,493 @@ +local domain = require("clever_f.domain") +local sequence_state = require("clever_f.sequence_state") + +local M = {} +local StateTransitions = {} +StateTransitions.__index = StateTransitions +M.StateTransitions = StateTransitions + +local function fail(message, level) + error(message, (level or 1) + 1) +end + +local function is_integer(value) + return type(value) == "number" + and value > -math.huge + and value < math.huge + and value == math.floor(value) +end + +local function require_time(value, name) + if type(value) ~= "number" + or value ~= value + or value <= -math.huge + or value >= math.huge + then + fail((name or "time") .. " must be a finite number", 2) + end + return value +end + +local function require_identity(identity, name) + if identity == nil then + fail((name or "resource identity") .. " must be active", 2) + end + return identity +end + +local function require_location(location, name) + if location == nil then + fail((name or "resource location") .. " must identify its host location", 2) + end + return location +end + +local function require_target(target) + if not domain.TargetValue.is(target) then + fail("acquired target must be a TargetValue", 2) + end + return target +end + +local function require_position(position) + return domain.Position.coerce(position) +end + +local function moved_forward_value(direction) + if type(direction) == "boolean" then + return direction + end + if domain.Direction.is(direction) then + return direction == domain.Direction.FORWARD + end + if domain.Descriptor.is(direction) then + return direction.direction == domain.Direction.FORWARD + end + if direction == "forward" or direction == "backward" then + return direction == "forward" + end + fail("movement direction must be a Boolean, Direction, or Descriptor", 2) +end + +local function copy_resource(resource) + local result = {} + for key, value in pairs(resource) do + result[key] = value + end + return result +end + +local function resource_matches(resource, identity, location_field, location) + return resource.identity == identity + and (location == nil or resource[location_field] == location) +end + +local function add_unique_resource(resources, resource, location_field, field_name) + for _, active in ipairs(resources) do + if resource_matches( + active, + resource.identity, + location_field, + resource[location_field] + ) then + fail(field_name .. " resource is already active at this host location", 3) + end + end + resources[#resources + 1] = resource + return copy_resource(resource) +end + +local function remove_resources(data, field, predicate) + local removed = {} + local retained = {} + for _, resource in ipairs(data[field]) do + if predicate(resource) then + removed[#removed + 1] = copy_resource(resource) + else + retained[#retained + 1] = resource + end + end + data[field] = retained + return removed +end + +local function clear_target_overlays(data, window) + return remove_resources(data, "target_overlays", function(resource) + return window == nil or resource.window == window + end) +end + +local function clear_temporary_overlays(data, window) + return remove_resources(data, "temporary_overlays", function(resource) + return window == nil or resource.window == window + end) +end + +local function clear_finalizers(data, buffer) + return remove_resources(data, "finalizers", function(resource) + return buffer == nil or resource.buffer == buffer + end) +end + +local function clear_highlight_timer(data) + local identity = data.highlight_timer + data.highlight_timer = nil + return identity +end + +local function clear_all_landings_and_direction(data) + data.previous_landing = {} + data.moved_forward = false +end + +local function public_reset(data, current_window) + local cleanup = { + highlight_timer = clear_highlight_timer(data), + target_overlays = clear_target_overlays(data, current_window), + finalizers = {}, + temporary_overlays = {}, + } + data.previous_descriptor = {} + data.previous_landing = {} + data.first_move = {} + data.migemo_cache = {} + data.repeat_timestamp_ms = 0 + return cleanup +end + +function StateTransitions.new(state) + state = state or sequence_state.get() + if not sequence_state.is(state) then + fail("StateTransitions requires the plugin-global SequenceState", 2) + end + return setmetatable({ _state = state }, StateTransitions) +end + +function M.new(state) + return StateTransitions.new(state) +end + +setmetatable(M, { + __call = function(_, state) + return StateTransitions.new(state) + end, +}) + +function StateTransitions:state() + return self._state +end + +function StateTransitions:_mutate(mutation) + return sequence_state._mutate(self._state, mutation) +end + +function StateTransitions:BeginAcquisition(context, descriptor) + context = sequence_state._normalize_context(context) + descriptor = domain.Descriptor.from_string(descriptor) + return self:_mutate(function(state) + state.known_contexts[context] = true + state.previous_descriptor[context] = descriptor + state.first_move[context] = true + return self._state:get_context(context) + end) +end + +function StateTransitions:CommitAcquiredTarget(context, target, time_ms) + context = sequence_state._normalize_context(context) + target = require_target(target) + if time_ms ~= nil then + time_ms = require_time(time_ms, "acquisition time") + end + return self:_mutate(function(state) + state.known_contexts[context] = true + state.previous_target[context] = target + state.last_input_context = context + if time_ms ~= nil then + state.repeat_timestamp_ms = time_ms + end + return self._state:get_context(context) + end) +end + +function StateTransitions:CommitCommandSuccess(context, destination, direction) + context = sequence_state._normalize_context(context) + destination = require_position(destination) + local forward = moved_forward_value(direction) + return self:_mutate(function(state) + state.known_contexts[context] = true + state.moved_forward = forward + state.moved_forward_initialized = true + state.previous_landing[context] = destination + state.first_move[context] = false + return self._state:get_context(context) + end) +end + +function StateTransitions:CommitVisualSuccess(context, destination) + context = sequence_state._normalize_context(context) + destination = require_position(destination) + return self:_mutate(function(state) + state.known_contexts[context] = true + state.previous_landing[context] = destination + state.first_move[context] = false + return self._state:get_context(context) + end) +end + +function StateTransitions:ClearAllLandingsAndDirection() + return self:_mutate(function(state) + clear_all_landings_and_direction(state) + end) +end + +function StateTransitions:SetRepeatTimestamp(time_ms) + time_ms = require_time(time_ms, "repeat timestamp") + return self:_mutate(function(state) + local previous = state.repeat_timestamp_ms + state.repeat_timestamp_ms = time_ms + return previous + end) +end + +function StateTransitions:CacheMigemo(encoding, dictionary) + if type(encoding) ~= "string" or encoding == "" then + fail("Migemo cache encoding must be a nonempty string", 2) + end + if dictionary == nil then + fail("Migemo cache dictionary must be present", 2) + end + return self:_mutate(function(state) + local previous = state.migemo_cache[encoding] + state.migemo_cache[encoding] = dictionary + return previous + end) +end + +function StateTransitions:RemoveMigemo(encoding) + if type(encoding) ~= "string" or encoding == "" then + fail("Migemo cache encoding must be a nonempty string", 2) + end + return self:_mutate(function(state) + local previous = state.migemo_cache[encoding] + state.migemo_cache[encoding] = nil + return previous + end) +end + +function StateTransitions:ClearMigemoCache() + return self:_mutate(function(state) + local previous = state.migemo_cache + state.migemo_cache = {} + return previous + end) +end + +function StateTransitions:SetHighlightTimer(identity) + return self:_mutate(function(state) + local previous = state.highlight_timer + state.highlight_timer = identity + return previous + end) +end + +function StateTransitions:ClearHighlightTimer(expected_identity) + return self:_mutate(function(state) + local current = state.highlight_timer + if current == nil then + return nil, false + end + if expected_identity ~= nil and current ~= expected_identity then + return nil, false + end + state.highlight_timer = nil + return current, true + end) +end + +function StateTransitions:AddTargetOverlay(identity, window, anchor_line) + if type(identity) == "table" and window == nil and identity.identity ~= nil then + local resource = identity + identity = resource.identity + window = resource.window + anchor_line = resource.anchor_line + end + require_identity(identity, "target overlay identity") + require_location(window, "target overlay window") + if anchor_line ~= nil and (not is_integer(anchor_line) or anchor_line < 1) then + fail("target overlay anchor_line must be a positive integer", 2) + end + local resource = { + identity = identity, + window = window, + group = "CleverFChar", + anchor_line = anchor_line, + } + return self:_mutate(function(state) + return add_unique_resource( + state.target_overlays, + resource, + "window", + "target overlay" + ) + end) +end + +function StateTransitions:RemoveTargetOverlay(identity, window) + require_identity(identity, "target overlay identity") + return self:_mutate(function(state) + return remove_resources(state, "target_overlays", function(resource) + return resource_matches(resource, identity, "window", window) + end) + end) +end + +function StateTransitions:ClearTargetOverlays(window) + return self:_mutate(function(state) + return clear_target_overlays(state, window) + end) +end + +function StateTransitions:AddTemporaryOverlay(identity, window, group) + if type(identity) == "table" and window == nil and identity.identity ~= nil then + local resource = identity + identity = resource.identity + window = resource.window + group = resource.group + end + require_identity(identity, "temporary overlay identity") + require_location(window, "temporary overlay window") + group = group or "CleverFCursor" + if group ~= "CleverFCursor" and group ~= "CleverFDirect" then + fail("temporary overlay group must be CleverFCursor or CleverFDirect", 2) + end + local resource = { + identity = identity, + window = window, + group = group, + } + return self:_mutate(function(state) + return add_unique_resource( + state.temporary_overlays, + resource, + "window", + "temporary overlay" + ) + end) +end + +function StateTransitions:RemoveTemporaryOverlay(identity, window) + require_identity(identity, "temporary overlay identity") + return self:_mutate(function(state) + return remove_resources(state, "temporary_overlays", function(resource) + return resource_matches(resource, identity, "window", window) + end) + end) +end + +function StateTransitions:ClearTemporaryOverlays(window) + return self:_mutate(function(state) + return clear_temporary_overlays(state, window) + end) +end + +function StateTransitions:AddFinalizer(identity, buffer) + if type(identity) == "table" and buffer == nil and identity.identity ~= nil then + local resource = identity + identity = resource.identity + buffer = resource.buffer + end + require_identity(identity, "finalizer identity") + require_location(buffer, "finalizer buffer") + local resource = { + identity = identity, + buffer = buffer, + } + return self:_mutate(function(state) + return add_unique_resource(state.finalizers, resource, "buffer", "finalizer") + end) +end + +function StateTransitions:RemoveFinalizer(identity, buffer) + require_identity(identity, "finalizer identity") + return self:_mutate(function(state) + return remove_resources(state, "finalizers", function(resource) + return resource_matches(resource, identity, "buffer", buffer) + end) + end) +end + +function StateTransitions:ClearFinalizers(buffer) + return self:_mutate(function(state) + return clear_finalizers(state, buffer) + end) +end + +function StateTransitions:ClearTargetFeedback(current_window) + return self:_mutate(function(state) + return { + highlight_timer = clear_highlight_timer(state), + target_overlays = clear_target_overlays(state, current_window), + finalizers = {}, + temporary_overlays = {}, + } + end) +end + +function StateTransitions:FullFinalization(current_window) + return self:_mutate(function(state) + local cleanup = { + highlight_timer = clear_highlight_timer(state), + target_overlays = clear_target_overlays(state, current_window), + finalizers = clear_finalizers(state), + temporary_overlays = {}, + } + clear_all_landings_and_direction(state) + return cleanup + end) +end + +function StateTransitions:PublicReset(current_window) + return self:_mutate(function(state) + return public_reset(state, current_window) + end) +end + +function StateTransitions:DiagnosticFullReset(current_window) + return self:_mutate(function(state) + local cleanup = public_reset(state, current_window) + cleanup.finalizers = clear_finalizers(state) + state.previous_target = {} + state.last_input_context = nil + state.moved_forward = false + state.moved_forward_initialized = false + return cleanup + end) +end + +StateTransitions.begin_acquisition = StateTransitions.BeginAcquisition +StateTransitions.commit_acquired_target = StateTransitions.CommitAcquiredTarget +StateTransitions.commit_command_success = StateTransitions.CommitCommandSuccess +StateTransitions.commit_visual_success = StateTransitions.CommitVisualSuccess +StateTransitions.clear_all_landings_and_direction = + StateTransitions.ClearAllLandingsAndDirection +StateTransitions.set_repeat_timestamp = StateTransitions.SetRepeatTimestamp +StateTransitions.cache_migemo = StateTransitions.CacheMigemo +StateTransitions.remove_migemo = StateTransitions.RemoveMigemo +StateTransitions.clear_migemo_cache = StateTransitions.ClearMigemoCache +StateTransitions.set_highlight_timer = StateTransitions.SetHighlightTimer +StateTransitions.clear_highlight_timer = StateTransitions.ClearHighlightTimer +StateTransitions.add_target_overlay = StateTransitions.AddTargetOverlay +StateTransitions.remove_target_overlay = StateTransitions.RemoveTargetOverlay +StateTransitions.clear_target_overlays = StateTransitions.ClearTargetOverlays +StateTransitions.add_temporary_overlay = StateTransitions.AddTemporaryOverlay +StateTransitions.remove_temporary_overlay = StateTransitions.RemoveTemporaryOverlay +StateTransitions.clear_temporary_overlays = StateTransitions.ClearTemporaryOverlays +StateTransitions.add_finalizer = StateTransitions.AddFinalizer +StateTransitions.remove_finalizer = StateTransitions.RemoveFinalizer +StateTransitions.clear_finalizers = StateTransitions.ClearFinalizers +StateTransitions.clear_target_feedback = StateTransitions.ClearTargetFeedback +StateTransitions.full_finalization = StateTransitions.FullFinalization +StateTransitions.public_reset = StateTransitions.PublicReset +StateTransitions.diagnostic_full_reset = StateTransitions.DiagnosticFullReset + +return M diff --git a/tests/run.lua b/tests/run.lua index f984123..2eb49b3 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9,6 +9,8 @@ package.path = table.concat({ local domain = require("clever_f.domain") local capabilities = require("clever_f.capabilities") local policy = require("clever_f.policy") +local sequence_state = require("clever_f.sequence_state") +local state_transitions = require("clever_f.state_transitions") local MemoryHost = require("clever_f.testing.memory_host") local tests = {} @@ -832,6 +834,369 @@ test("Unsupported Migemo policy mutation updates live configuration", function() truthy(service:get_boolean("use_migemo")) end) +local function fresh_sequence_state() + local transitions = state_transitions.new() + transitions:ClearTemporaryOverlays() + transitions:DiagnosticFullReset() + return sequence_state.get(), transitions +end + +local function map_size(values) + local count = 0 + for _ in pairs(values) do + count = count + 1 + end + return count +end + +test("SequenceState has one empty plugin-global instance", function() + local state, transitions = fresh_sequence_state() + same(state, sequence_state.new()) + same(state, sequence_state.global) + same(state, transitions:state()) + same(state, state_transitions.new():state()) + + same(0, map_size(state.previous_descriptor)) + same(0, map_size(state.previous_landing)) + same(0, map_size(state.first_move)) + same(0, map_size(state.previous_target)) + same(0, map_size(state.migemo_cache)) + same(nil, state.last_input_context) + falsy(state.moved_forward) + falsy(state.moved_forward_initialized) + same(0, state.repeat_timestamp_ms) + same(1, state.repeat_timestamp_ms + 1) + same(nil, state.highlight_timer) + same(0, #state.target_overlays) + same(0, #state.temporary_overlays) + same(0, #state.finalizers) +end) + +test("Per-context transitions use normalized ModeContext keys", function() + local state, transitions = fresh_sequence_state() + local operator = domain.ModeContext.from_full_mode("no") + local target = domain.TargetValue.character("x", 120) + + transitions:BeginAcquisition("nov", "F") + transitions:CommitAcquiredTarget("no" .. string.char(0x16), target, 12.5) + + same(domain.Descriptor.FIND_BACKWARD, state:get_previous_descriptor("noV")) + truthy(state:get_first_move(operator)) + same(target, state:get_previous_target("no")) + same(operator, state.last_input_context) + same(12.5, state.repeat_timestamp_ms) + same(1, map_size(state.previous_descriptor)) + same(domain.Descriptor.FIND_BACKWARD, state.previous_descriptor[operator]) + + transitions:CommitCommandSuccess("noV", { line = 3, byte_column = 6 }, false) + local serialized = state:to_table() + same(3, serialized.contexts.no.previous_landing.line) + same(6, serialized.contexts.no.previous_landing.byte_column) + same(2, map_size(serialized.contexts.no.previous_landing)) + same(nil, serialized.contexts.nov) + + local normal_target = domain.TargetValue.character("a", 97) + local visual_target = domain.TargetValue.character("b", 98) + transitions:BeginAcquisition("n", "f") + transitions:CommitAcquiredTarget("n", normal_target) + transitions:BeginAcquisition("v", "t") + transitions:CommitAcquiredTarget("v", visual_target) + + same(normal_target, state:get_previous_target("n")) + same(visual_target, state:get_previous_target("v")) + same(domain.Descriptor.FIND_FORWARD, state:get_previous_descriptor("n")) + same(domain.Descriptor.TILL_FORWARD, state:get_previous_descriptor("v")) + same(domain.ModeContext.from_full_mode("v"), state.last_input_context) +end) + +test("Success transitions commit only their mode-sensitive fields", function() + local state, transitions = fresh_sequence_state() + local target = domain.TargetValue.character("h", 104) + local command_destination = domain.Position.new(2, 4) + + transitions:BeginAcquisition("n", "f") + transitions:CommitAcquiredTarget("n", target, 31) + transitions:CommitCommandSuccess( + "n", + command_destination, + domain.Direction.FORWARD + ) + + same(command_destination, state:get_previous_landing("n")) + falsy(state:get_first_move("n")) + truthy(state.moved_forward) + truthy(state.moved_forward_initialized) + + local visual_destination = domain.Position.new(3, 2) + transitions:BeginAcquisition("v", "T") + transitions:CommitAcquiredTarget("v", target) + transitions:CommitVisualSuccess("v", visual_destination) + + same(visual_destination, state:get_previous_landing("v")) + falsy(state:get_first_move("v")) + truthy(state.moved_forward, "Visual success must retain movement direction") + same(31, state.repeat_timestamp_ms, "an absent acquisition time must be retained") + + transitions:CommitCommandSuccess("n", domain.Position.new(1, 1), "backward") + falsy(state.moved_forward) + truthy(state.moved_forward_initialized) +end) + +test("Failed and partial outcomes preserve successful history silently", function() + local state, transitions = fresh_sequence_state() + local target = domain.TargetValue.character("a", 97) + local landing = domain.Position.new(1, 5) + + transitions:BeginAcquisition("n", "f") + transitions:CommitAcquiredTarget("n", target, 17) + transitions:CommitCommandSuccess("n", landing, true) + + local descriptor = state:get_previous_descriptor("n") + local first_move = state:get_first_move("n") + local moved_forward = state.moved_forward + local before_timestamp = state.repeat_timestamp_ms + + local failed = domain.ActionOutcome.from_search( + domain.SearchOutcome.boundary_before_any(landing), + "f" + ) + local partial_endpoint = domain.Position.new(1, 7) + local partial = domain.ActionOutcome.from_search( + domain.SearchOutcome.boundary_after_partial(partial_endpoint, 2), + "f" + ) + + same(domain.ActionKind.FAILED_SEARCH, failed.kind) + same(nil, failed.diagnostic) + same(0, failed.successful_steps) + same(domain.ActionKind.FAILED_SEARCH, partial.kind) + same(partial_endpoint, partial.position) + same(2, partial.successful_steps) + same(nil, partial.diagnostic) + + same(descriptor, state:get_previous_descriptor("n")) + same(landing, state:get_previous_landing("n")) + same(first_move, state:get_first_move("n")) + same(target, state:get_previous_target("n")) + same(moved_forward, state.moved_forward) + same(before_timestamp, state.repeat_timestamp_ms) +end) + +test("State resource updates retain host locations and timer identity", function() + local state, transitions = fresh_sequence_state() + + transitions:AddTargetOverlay("char-1", "window-1", 4) + transitions:AddTargetOverlay("char-1", "window-2", 8) + transitions:AddTemporaryOverlay("cursor-1", "window-1", "CleverFCursor") + transitions:AddTemporaryOverlay("direct-1", "window-2", "CleverFDirect") + transitions:AddFinalizer("finalizer-1", "buffer-1") + transitions:AddFinalizer("finalizer-1", "buffer-2") + + local resources = state:resources() + same("window-1", resources.target_overlays[1].window) + same(4, resources.target_overlays[1].anchor_line) + same("window-2", resources.target_overlays[2].window) + same("CleverFDirect", resources.temporary_overlays[2].group) + same("buffer-1", resources.finalizers[1].buffer) + same("buffer-2", resources.finalizers[2].buffer) + + resources.target_overlays[1].window = "changed" + same("window-1", state.target_overlays[1].window) + fails(function() + transitions:AddTargetOverlay("char-1", "window-1") + end, "already active") + fails(function() + transitions:AddFinalizer("missing-location") + end, "host location") + + same(nil, transitions:SetHighlightTimer("timer-1")) + same("timer-1", transitions:SetHighlightTimer("timer-2")) + local cleared, current = transitions:ClearHighlightTimer("timer-1") + same(nil, cleared) + falsy(current) + same("timer-2", state.highlight_timer) + cleared, current = transitions:ClearHighlightTimer("timer-2") + same("timer-2", cleared) + truthy(current) + same(nil, state.highlight_timer) + + local removed = transitions:RemoveTargetOverlay("char-1", "window-1") + same(1, #removed) + same("window-1", removed[1].window) + same(1, #state.target_overlays) + same("window-2", state.target_overlays[1].window) +end) + +test("SequenceState exposes copies and rejects direct mutation", function() + local state, transitions = fresh_sequence_state() + transitions:BeginAcquisition("n", "t") + transitions:AddTargetOverlay("char-copy", "window-copy", 2) + + fails(function() + state.moved_forward = true + end, "StateTransitions") + + local descriptor_map = state.previous_descriptor + descriptor_map[domain.ModeContext.from_full_mode("n")] = nil + same(domain.Descriptor.TILL_FORWARD, state:get_previous_descriptor("n")) + + local overlays = state.target_overlays + overlays[1].anchor_line = 99 + overlays[1] = nil + same(2, state.target_overlays[1].anchor_line) +end) + +test("PublicReset applies its exact clear and retain sets", function() + local state, transitions = fresh_sequence_state() + local normal_target = domain.TargetValue.character("h", 104) + local visual_target = domain.TargetValue.character("x", 120) + + transitions:BeginAcquisition("n", "f") + transitions:CommitAcquiredTarget("n", normal_target, 77) + transitions:CommitCommandSuccess("n", domain.Position.new(1, 2), true) + transitions:BeginAcquisition("v", "t") + transitions:CommitAcquiredTarget("v", visual_target) + transitions:CommitVisualSuccess("v", domain.Position.new(2, 3)) + transitions:CacheMigemo("utf-8", { dictionary = "utf-8" }) + transitions:SetHighlightTimer("timer-1") + transitions:AddTargetOverlay("char-1", "window-1", 1) + transitions:AddTargetOverlay("char-2", "window-2", 2) + transitions:AddTemporaryOverlay("cursor-1", "window-1", "CleverFCursor") + transitions:AddFinalizer("finalizer-1", "buffer-1") + + local input_context = state.last_input_context + local cleanup = transitions:PublicReset("window-1") + + same(nil, state:get_previous_descriptor("n")) + same(nil, state:get_previous_descriptor("v")) + same(nil, state:get_previous_landing("n")) + same(nil, state:get_previous_landing("v")) + same(nil, state:get_first_move("n")) + same(nil, state:get_first_move("v")) + same(normal_target, state:get_previous_target("n")) + same(visual_target, state:get_previous_target("v")) + same(input_context, state.last_input_context) + truthy(state.moved_forward) + truthy(state.moved_forward_initialized) + same(0, map_size(state.migemo_cache)) + same(0, state.repeat_timestamp_ms) + same(nil, state.highlight_timer) + same(1, #state.target_overlays) + same("window-2", state.target_overlays[1].window) + same(1, #state.temporary_overlays) + same(1, #state.finalizers) + + same("timer-1", cleanup.highlight_timer) + same(1, #cleanup.target_overlays) + same("char-1", cleanup.target_overlays[1].identity) + same("window-1", cleanup.target_overlays[1].window) + same(0, #cleanup.finalizers) +end) + +test("FullFinalization clears feedback and direction while retaining history", function() + local state, transitions = fresh_sequence_state() + local normal_target = domain.TargetValue.character("h", 104) + local visual_target = domain.TargetValue.character("x", 120) + local dictionary = { dictionary = "utf-8" } + + transitions:BeginAcquisition("n", "f") + transitions:CommitAcquiredTarget("n", normal_target, 91) + transitions:CommitCommandSuccess("n", domain.Position.new(1, 2), true) + transitions:BeginAcquisition("v", "T") + transitions:CommitAcquiredTarget("v", visual_target) + transitions:CommitVisualSuccess("v", domain.Position.new(2, 3)) + transitions:CacheMigemo("utf-8", dictionary) + transitions:SetHighlightTimer("timer-finalize") + transitions:AddTargetOverlay("char-current", "window-1", 1) + transitions:AddTargetOverlay("char-peer", "window-2", 2) + transitions:AddTemporaryOverlay("direct-current", "window-1", "CleverFDirect") + transitions:AddFinalizer("finalizer-a", "buffer-1") + transitions:AddFinalizer("finalizer-b", "buffer-2") + + local input_context = state.last_input_context + local cleanup = transitions:FullFinalization("window-1") + + same(nil, state:get_previous_landing("n")) + same(nil, state:get_previous_landing("v")) + falsy(state.moved_forward) + truthy(state.moved_forward_initialized) + same(domain.Descriptor.FIND_FORWARD, state:get_previous_descriptor("n")) + same(domain.Descriptor.TILL_BACKWARD, state:get_previous_descriptor("v")) + same(normal_target, state:get_previous_target("n")) + same(visual_target, state:get_previous_target("v")) + falsy(state:get_first_move("n")) + falsy(state:get_first_move("v")) + same(input_context, state.last_input_context) + same(dictionary, state:get_migemo("utf-8")) + same(91, state.repeat_timestamp_ms) + same(nil, state.highlight_timer) + same(1, #state.target_overlays) + same("window-2", state.target_overlays[1].window) + same(1, #state.temporary_overlays) + same(0, #state.finalizers) + + same("timer-finalize", cleanup.highlight_timer) + same("window-1", cleanup.target_overlays[1].window) + same(2, #cleanup.finalizers) + same("buffer-1", cleanup.finalizers[1].buffer) +end) + +test("DiagnosticFullReset adds only its diagnostic clear set", function() + local state, transitions = fresh_sequence_state() + local target = domain.TargetValue.character("z", 122) + + transitions:BeginAcquisition("n", "f") + transitions:CommitAcquiredTarget("n", target, 55) + transitions:CommitCommandSuccess("n", domain.Position.new(4, 7), true) + transitions:CacheMigemo("cp932", { dictionary = "cp932" }) + transitions:SetHighlightTimer("timer-diagnostic") + transitions:AddTargetOverlay("char-diagnostic", "window-3", 4) + transitions:AddTemporaryOverlay("cursor-diagnostic", "window-3", "CleverFCursor") + transitions:AddFinalizer("finalizer-diagnostic", "buffer-3") + + local cleanup = transitions:DiagnosticFullReset() + + same(nil, state:get_previous_descriptor("n")) + same(nil, state:get_previous_landing("n")) + same(nil, state:get_first_move("n")) + same(nil, state:get_previous_target("n")) + same(nil, state.last_input_context) + falsy(state.moved_forward) + falsy(state.moved_forward_initialized) + same(0, map_size(state.migemo_cache)) + same(0, state.repeat_timestamp_ms) + same(nil, state.highlight_timer) + same(0, #state.target_overlays) + same(1, #state.temporary_overlays) + same("window-3", state.temporary_overlays[1].window) + same(0, #state.finalizers) + + same("timer-diagnostic", cleanup.highlight_timer) + same("window-3", cleanup.target_overlays[1].window) + same("buffer-3", cleanup.finalizers[1].buffer) + transitions:ClearTemporaryOverlays() +end) + +test("ClearAllLandingsAndDirection retains movement initialization", function() + local state, transitions = fresh_sequence_state() + local target = domain.TargetValue.character("a", 97) + transitions:BeginAcquisition("n", "f") + transitions:CommitAcquiredTarget("n", target) + transitions:CommitCommandSuccess("n", domain.Position.new(1, 9), true) + transitions:BeginAcquisition("v", "t") + transitions:CommitVisualSuccess("v", domain.Position.new(2, 1)) + + transitions:ClearAllLandingsAndDirection() + + same(nil, state:get_previous_landing("n")) + same(nil, state:get_previous_landing("v")) + falsy(state.moved_forward) + truthy(state.moved_forward_initialized) + same(domain.Descriptor.FIND_FORWARD, state:get_previous_descriptor("n")) + same(target, state:get_previous_target("n")) + same(domain.Descriptor.TILL_FORWARD, state:get_previous_descriptor("v")) +end) + for _, item in ipairs(tests) do local ok, failure = xpcall(item.body, debug.traceback) if not ok then @@ -841,4 +1206,4 @@ for _, item in ipairs(tests) do passed = passed + 1 end -io.stdout:write(string.format("Phase 3: %d tests passed\n", passed)) +io.stdout:write(string.format("Phase 4: %d tests passed\n", passed)) |
