## #503: Auto-checklist progress tracking — unit + integration tests. ## ## Tests cover: ## 1. YAML parser: basic types, conditions array, edge cases ## 2. Condition evaluation: all 7 condition types ## 3. Latching: conditions stay met once satisfied ## 4. Room change: per-room conditions reset, cross-room conditions persist ## 5. Overlay: visibility gating on gauntlet_mode ## 6. Integration: snapshot -> GameState -> evaluator -> overlay ## ## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof. class_name TestChecklist extends GdUnitTestSuite var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd") var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.gd") func before_test() -> void: SimBridge.reset_test_state() SimBridge._last_snapshot = null GameState.current_tick = 0 GameState.player_position = Vector2.ZERO GameState.visible_entities = [] GameState.visible_tiles = [] GameState.visible_positions = {} GameState.current_monologue = null GameState.current_dialogue = null GameState.nearby_interactions = [] GameState.game_time = {} GameState.pending_recognitions = [] GameState.room_id = null GameState.gauntlet_mode = false GameState.player_facing = "North" GameState.player_stance = "Walk" GameState.player_inventory = [] # -- YAML Parser Tests --------------------------------------------------------- func test_parse_empty_yaml() -> void: var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("") assert_that(result.size()).is_equal(0) func test_parse_top_level_string() -> void: var yaml := "room_id: inventory_warehouse" var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) assert_that(result.get("room_id")).is_equal("inventory_warehouse") func test_parse_top_level_quoted_string() -> void: var yaml := 'description: "Tests D-065 (9-slot inventory)."' var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).") func test_parse_single_condition() -> void: var yaml := ("conditions:\n - id: test-1\n description: \"Test condition\"\n" + " condition_type: player_near\n x: 10\n y: 20\n radius: 3.0") var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) assert_that(result.has("conditions")).is_true() var conditions: Array = result["conditions"] assert_that(conditions.size()).is_equal(1) assert_that(conditions[0]["id"]).is_equal("test-1") assert_that(conditions[0]["condition_type"]).is_equal("player_near") assert_that(conditions[0]["x"]).is_equal(10) assert_that(conditions[0]["y"]).is_equal(20) assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001) func test_parse_multiple_conditions() -> void: var yaml := ("conditions:\n - id: cond-a\n condition_type: player_near\n" + " x: 1\n y: 2\n radius: 1.0\n\n" + " - id: cond-b\n condition_type: player_facing\n direction: East") var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) var conditions: Array = result["conditions"] assert_that(conditions.size()).is_equal(2) assert_that(conditions[0]["id"]).is_equal("cond-a") assert_that(conditions[1]["id"]).is_equal("cond-b") assert_that(conditions[1]["direction"]).is_equal("East") func test_parse_comments_ignored() -> void: var yaml := ("# This is a comment\nroom_id: test\n# Another comment\n" + "conditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5") var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) assert_that(result.get("room_id")).is_equal("test") var conditions: Array = result["conditions"] assert_that(conditions.size()).is_equal(1) assert_that(conditions[0]["entity_id"]).is_equal(5) func test_parse_integer_and_float_values() -> void: var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5" var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) var cond: Dictionary = result["conditions"][0] assert_that(cond["x"]).is_equal(42) assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT) func test_parse_scope_field() -> void: var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0" var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) assert_that(result.get("scope")).is_equal("cross_room") func test_parse_inline_comment_stripped() -> void: var yaml := "room_id: test # this is a comment" var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) assert_that(result.get("room_id")).is_equal("test") # -- Condition Evaluation Tests ------------------------------------------------ func _make_evaluator(conditions: Array): var evaluator = ChecklistEvaluatorScript.new() evaluator._room_conditions = conditions evaluator._loaded = true return evaluator func test_eval_player_near_within_radius() -> void: GameState.player_position = Vector2(10.0, 20.0) var evaluator = _make_evaluator([{ "id": "near-1", "condition_type": "player_near", "x": 10, "y": 21, "radius": 2.0, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "player_near: player at (10,20), target (10,21), radius 2.0 — should be met" ).is_equal(1) func test_eval_player_near_outside_radius() -> void: GameState.player_position = Vector2(10.0, 20.0) var evaluator = _make_evaluator([{ "id": "near-2", "condition_type": "player_near", "x": 10, "y": 30, "radius": 2.0, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met" ).is_equal(0) func test_eval_player_near_exact_boundary() -> void: GameState.player_position = Vector2(10.0, 20.0) var evaluator = _make_evaluator([{ "id": "near-3", "condition_type": "player_near", "x": 10, "y": 22, "radius": 2.0, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "player_near: distance exactly equals radius — should be met (<=)" ).is_equal(1) func test_eval_player_facing_match() -> void: GameState.player_facing = "East" var evaluator = _make_evaluator([{ "id": "face-1", "condition_type": "player_facing", "direction": "East", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) func test_eval_player_facing_no_match() -> void: GameState.player_facing = "North" var evaluator = _make_evaluator([{ "id": "face-2", "condition_type": "player_facing", "direction": "East", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(0) func test_eval_player_facing_diagonal_no_match() -> void: # 8-directional facing "Northeast" should NOT match "East" or "North" GameState.player_facing = "Northeast" var evaluator = _make_evaluator([{ "id": "face-diag", "condition_type": "player_facing", "direction": "East", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "player_facing: Northeast should NOT match East (exact match only)" ).is_equal(0) func test_eval_entity_present_found() -> void: GameState.visible_entities = [ {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, {"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"}, ] var evaluator = _make_evaluator([{ "id": "present-1", "condition_type": "entity_present", "entity_id": 10, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) func test_eval_entity_present_not_found() -> void: GameState.visible_entities = [ {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, ] var evaluator = _make_evaluator([{ "id": "present-2", "condition_type": "entity_present", "entity_id": 99, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(0) func test_eval_entity_absent_when_not_visible() -> void: GameState.visible_entities = [ {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, ] var evaluator = _make_evaluator([{ "id": "absent-1", "condition_type": "entity_absent", "entity_id": 99, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "entity_absent: entity 99 not in visible_entities — should be met" ).is_equal(1) func test_eval_entity_absent_when_visible() -> void: GameState.visible_entities = [ {"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, ] var evaluator = _make_evaluator([{ "id": "absent-2", "condition_type": "entity_absent", "entity_id": 10, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "entity_absent: entity 10 IS visible — should NOT be met" ).is_equal(0) func test_eval_expected_monologue_match() -> void: GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0} var evaluator = _make_evaluator([{ "id": "mono-1", "condition_type": "expected_monologue", "contains": "wrong here", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) func test_eval_expected_monologue_no_match() -> void: GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0} var evaluator = _make_evaluator([{ "id": "mono-2", "condition_type": "expected_monologue", "contains": "wrong here", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(0) func test_eval_expected_monologue_null() -> void: GameState.current_monologue = null var evaluator = _make_evaluator([{ "id": "mono-3", "condition_type": "expected_monologue", "contains": "test", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "expected_monologue: null monologue should not match" ).is_equal(0) func test_eval_expected_dialogue_match() -> void: GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []} var evaluator = _make_evaluator([{ "id": "dlg-1", "condition_type": "expected_dialogue", "contains": "Who are you", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) func test_eval_expected_dialogue_no_match() -> void: GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []} var evaluator = _make_evaluator([{ "id": "dlg-2", "condition_type": "expected_dialogue", "contains": "Goodbye", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(0) func test_eval_expected_dialogue_null() -> void: GameState.current_dialogue = null var evaluator = _make_evaluator([{ "id": "dlg-3", "condition_type": "expected_dialogue", "contains": "test", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "expected_dialogue: null dialogue should not match" ).is_equal(0) func test_eval_interaction_verb_match() -> void: GameState.nearby_interactions = [{ "entity_id": 13, "entity_type": "Object", "distance": 1, "verbs": [ {"kind": "Take", "label": "Pickup", "priority": 1, "available": true}, {"kind": "Observe", "label": "Examine", "priority": 2, "available": true}, ], }] var evaluator = _make_evaluator([{ "id": "verb-1", "condition_type": "expected_interaction_verb", "entity_id": 13, "verb": "Pickup", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) func test_eval_interaction_verb_by_kind() -> void: GameState.nearby_interactions = [{ "entity_id": 13, "entity_type": "Object", "distance": 1, "verbs": [ {"kind": "Take", "label": "Pickup", "priority": 1, "available": true}, ], }] var evaluator = _make_evaluator([{ "id": "verb-kind", "condition_type": "expected_interaction_verb", "entity_id": 13, "verb": "Take", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "expected_interaction_verb: should match by kind='Take' as well as label" ).is_equal(1) func test_eval_interaction_verb_wrong_entity() -> void: GameState.nearby_interactions = [{ "entity_id": 13, "entity_type": "Object", "distance": 1, "verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}], }] var evaluator = _make_evaluator([{ "id": "verb-wrong", "condition_type": "expected_interaction_verb", "entity_id": 99, "verb": "Pickup", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "expected_interaction_verb: wrong entity_id should not match" ).is_equal(0) func test_eval_interaction_verb_unavailable() -> void: GameState.nearby_interactions = [{ "entity_id": 13, "entity_type": "Object", "distance": 1, "verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}], }] var evaluator = _make_evaluator([{ "id": "verb-unavail", "condition_type": "expected_interaction_verb", "entity_id": 13, "verb": "Pickup", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "expected_interaction_verb: unavailable verb should not match" ).is_equal(0) func test_eval_interaction_verb_no_interactions() -> void: GameState.nearby_interactions = [] var evaluator = _make_evaluator([{ "id": "verb-none", "condition_type": "expected_interaction_verb", "entity_id": 13, "verb": "Pickup", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(0) # -- Latching Tests ------------------------------------------------------------ func test_latching_condition_stays_met() -> void: # Condition met on first evaluate, stays met even when state changes. GameState.player_facing = "East" var evaluator = _make_evaluator([{ "id": "latch-1", "condition_type": "player_facing", "direction": "East", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) # Change state so condition would be false if re-evaluated fresh GameState.player_facing = "North" evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "Latched condition should stay met even after state changes" ).is_equal(1) func test_latching_monologue_transient() -> void: # Monologue appears for one tick, then disappears. Condition should latch. var evaluator = _make_evaluator([{ "id": "mono-latch", "condition_type": "expected_monologue", "contains": "recalibrated", }]) # Tick 1: no monologue GameState.current_monologue = null evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(0) # Tick 2: monologue fires GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0} evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) # Tick 3: monologue consumed (null) GameState.current_monologue = null evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "Monologue condition should stay latched after monologue disappears" ).is_equal(1) func test_multiple_conditions_partial_latching() -> void: var evaluator = _make_evaluator([ {"id": "c1", "condition_type": "player_facing", "direction": "East"}, {"id": "c2", "condition_type": "entity_present", "entity_id": 5}, {"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0}, ]) # Tick 1: only facing matches GameState.player_facing = "East" GameState.visible_entities = [] GameState.player_position = Vector2(0, 0) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) assert_that(evaluator.get_total_count()).is_equal(3) # Tick 2: entity also visible GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}] evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(2) # Tick 3: player moves to target GameState.player_position = Vector2(50.0, 50.0) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(3) assert_that(evaluator.is_complete()).is_true() # -- Room Change Tests --------------------------------------------------------- func test_room_change_resets_per_room_conditions() -> void: var evaluator = ChecklistEvaluatorScript.new() # Manually set conditions to avoid file loading evaluator._room_conditions = [ {"id": "r1-c1", "condition_type": "player_facing", "direction": "East"}, ] evaluator._loaded = true GameState.player_facing = "East" evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) # Simulate room change by loading a new "room" evaluator._current_room_id = "old_room" evaluator._room_conditions = [ {"id": "r2-c1", "condition_type": "player_facing", "direction": "North"}, ] # Clear latches for the new room (simulating load_room behavior) evaluator._latched.clear() evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "After room change, old latches should be cleared; new condition not met" ).is_equal(0) func test_reset_clears_all_state() -> void: var evaluator = _make_evaluator([ {"id": "c1", "condition_type": "player_facing", "direction": "East"}, ]) GameState.player_facing = "East" evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(1) evaluator.reset() assert_that(evaluator.is_loaded()).is_false() assert_that(evaluator.get_met_count()).is_equal(0) assert_that(evaluator.get_total_count()).is_equal(0) # -- get_results Tests --------------------------------------------------------- func test_get_results_structure() -> void: var evaluator = _make_evaluator([ {"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"}, ]) GameState.player_facing = "North" evaluator.evaluate() var results: Array = evaluator.get_results() assert_that(results.size()).is_equal(1) assert_that(results[0]["id"]).is_equal("c1") assert_that(results[0]["description"]).is_equal("Test condition") assert_that(results[0]["condition_type"]).is_equal("player_facing") assert_that(results[0]["met"]).is_true() func test_get_results_unmet() -> void: var evaluator = _make_evaluator([ {"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"}, ]) GameState.player_facing = "North" evaluator.evaluate() var results: Array = evaluator.get_results() assert_that(results[0]["met"]).is_false() # -- Overlay Visibility Tests -------------------------------------------------- func _make_checklist_overlay() -> Control: var overlay = Control.new() overlay.set_script(ChecklistOverlayScript) auto_free(overlay) add_child(overlay) return overlay func test_overlay_hidden_in_non_gauntlet_mode() -> void: var overlay := _make_checklist_overlay() GameState.gauntlet_mode = false overlay.update_from_state() assert_that(overlay.visible).override_failure_message( "Checklist overlay must be hidden in non-gauntlet mode" ).is_false() func test_overlay_visible_in_gauntlet_mode() -> void: var overlay := _make_checklist_overlay() GameState.gauntlet_mode = true GameState.room_id = "test_room" overlay.update_from_state() assert_that(overlay.visible).override_failure_message( "Checklist overlay must be visible in gauntlet mode" ).is_true() func test_overlay_hides_on_gauntlet_deactivation() -> void: var overlay := _make_checklist_overlay() GameState.gauntlet_mode = true GameState.room_id = "test_room" overlay.update_from_state() assert_that(overlay.visible).is_true() GameState.gauntlet_mode = false overlay.update_from_state() assert_that(overlay.visible).override_failure_message( "Overlay must hide when gauntlet mode deactivates" ).is_false() func test_overlay_evaluator_accessible() -> void: var overlay := _make_checklist_overlay() var evaluator = overlay.get_evaluator() assert_that(evaluator).override_failure_message( "Overlay should expose evaluator via get_evaluator()" ).is_not_null() func test_overlay_in_main_scene() -> void: var scene: PackedScene = load("res://scenes/main.tscn") var instance: Node = scene.instantiate() auto_free(instance) add_child(instance) instance._process(0.016) var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay") assert_that(overlay).override_failure_message( "ChecklistOverlay node should exist in main scene tree" ).is_not_null() if overlay is CanvasItem: assert_that((overlay as CanvasItem).visible).override_failure_message( "ChecklistOverlay should be hidden by default (non-gauntlet mode)" ).is_false() # -- Integration: Snapshot -> Evaluation ---------------------------------------- func test_integration_snapshot_to_evaluator() -> void: # Integration test: GameState snapshot data -> evaluator -> correct results. # Tests the evaluator directly (overlay wiring tested separately). var evaluator = ChecklistEvaluatorScript.new() evaluator._room_conditions = [ {"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1}, {"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"}, ] evaluator._loaded = true # Simulate gauntlet snapshot with player entity GameState.visible_entities = [ {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"}, ] GameState.player_facing = "North" evaluator.evaluate() # Entity present should be met, facing should not assert_that(evaluator.get_met_count()).is_equal(1) assert_that(evaluator.get_total_count()).is_equal(2) # Change facing — second condition should also latch GameState.player_facing = "East" evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(2) assert_that(evaluator.is_complete()).is_true() # Verify results array contains both conditions as met var results: Array = evaluator.get_results() for r in results: assert_that(r["met"]).override_failure_message( "Condition '%s' should be met after snapshot sequence" % r["id"] ).is_true() # -- Edge Cases ---------------------------------------------------------------- func test_empty_entity_list_entity_present() -> void: GameState.visible_entities = [] var evaluator = _make_evaluator([{ "id": "edge-empty", "condition_type": "entity_present", "entity_id": 0, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).is_equal(0) func test_empty_entity_list_entity_absent() -> void: GameState.visible_entities = [] var evaluator = _make_evaluator([{ "id": "edge-absent-empty", "condition_type": "entity_absent", "entity_id": 99, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "entity_absent with empty visible_entities should be met" ).is_equal(1) func test_zero_radius_player_near() -> void: GameState.player_position = Vector2(10.0, 20.0) var evaluator = _make_evaluator([{ "id": "edge-zero-radius", "condition_type": "player_near", "x": 10, "y": 20, "radius": 0.0, }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "player_near with radius 0 at exact position should be met" ).is_equal(1) func test_unknown_condition_type() -> void: var evaluator = _make_evaluator([{ "id": "edge-unknown", "condition_type": "nonexistent_type", }]) evaluator.evaluate() assert_that(evaluator.get_met_count()).override_failure_message( "Unknown condition type should not be met" ).is_equal(0) func test_no_conditions_loaded() -> void: var evaluator = ChecklistEvaluatorScript.new() assert_that(evaluator.is_loaded()).is_false() assert_that(evaluator.get_total_count()).is_equal(0) assert_that(evaluator.get_met_count()).is_equal(0) assert_that(evaluator.is_complete()).is_false() # -- Helper: recursive node search -------------------------------------------- func _find_node_recursive(root: Node, target_name: String) -> Node: if root.name == target_name: return root for child in root.get_children(): var found := _find_node_recursive(child, target_name) if found != null: return found return null