diff --git a/client/tests/test_implant_app_lifecycle.gd b/client/tests/test_implant_app_lifecycle.gd new file mode 100644 index 000000000..1d6c72b77 --- /dev/null +++ b/client/tests/test_implant_app_lifecycle.gd @@ -0,0 +1,281 @@ +class_name TestImplantAppLifecycle +extends GdUnitTestSuite +## Lifecycle tests for ImplantApp base class (#844, D-191, PR #131 item 5). +## Covers on_install → on_open → on_close hook ordering, nav-stack state at +## each hook, preserves_state semantics, on_insert_deactivated gating, +## and register_screen / current_screen_id behaviour. + +# Loaded inside method bodies to avoid class_name parse-order trap. +const APP_SCRIPT := "res://ui/implant/implant_app.gd" +const MANIFEST_SCRIPT := "res://ui/implant/implant_app_manifest.gd" + +const TEST_APP_PATH := "implant/lifecycle_test" + + +# Returns an ImplantApp instance with a manifest, added to the scene tree. +# _ready() fires on add_child, which calls on_install(). +func _make_app(preserves: bool = true, mode: String = "fullscreen"): # returns ImplantApp (untyped) + var ManifestClass := load(MANIFEST_SCRIPT) + var m = ManifestClass.new() + m.app_path = TEST_APP_PATH + m.default_mode = mode + m.preserves_state = preserves + m.schema_version = 1 + + var AppClass := load(APP_SCRIPT) + var app = AppClass.new() + app.manifest = m + add_child(app) # fires _ready() → on_install() + return app + + +func after_test() -> void: + # Restore HudGroups state — prevents cross-test bleed. + HudGroups._active_app = "" + HudGroups._active_mode = HudGroups.Mode.GAMEPLAY + HudGroups._groups.erase(TEST_APP_PATH) + + +# ============================================================================= +# on_install — called from _ready() +# ============================================================================= + + +func test_nav_created_after_ready() -> void: + # nav is created in _ready() before on_install() fires. + var app = _make_app() + assert_that(app.nav).override_failure_message( + "ImplantApp._ready() must create nav stack" + ).is_not_null() + app.queue_free() + + +func test_app_starts_invisible() -> void: + var app = _make_app() + assert_that(app.visible).override_failure_message( + "ImplantApp must start hidden (visible = false in _ready)" + ).is_false() + app.queue_free() + + +# ============================================================================= +# on_open — triggered via _internal_app_changed +# ============================================================================= + + +func test_open_fullscreen_makes_app_visible() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + assert_that(app.visible).override_failure_message( + "on_open(FULLSCREEN) must make app visible" + ).is_true() + app.queue_free() + + +func test_open_insert_makes_app_visible() -> void: + var app = _make_app(true, "insert") + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.INSERT) + assert_that(app.visible).override_failure_message( + "on_open(INSERT) must make app visible" + ).is_true() + app.queue_free() + + +func test_gameplay_mode_does_not_open_app() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.GAMEPLAY) + assert_that(app.visible).override_failure_message( + "GAMEPLAY mode must not make app visible" + ).is_false() + app.queue_free() + + +func test_wrong_app_path_does_not_open() -> void: + var app = _make_app() + app._internal_app_changed("implant/other", HudGroups.Mode.FULLSCREEN) + assert_that(app.visible).override_failure_message( + "App must not open when app_path does not match manifest" + ).is_false() + app.queue_free() + + +func test_on_open_nav_is_non_empty() -> void: + # Base class auto-pushes default on first open, so nav is guaranteed + # non-empty when on_open fires. + var app = _make_app() + app.nav.set_default("home") + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + assert_that(app.nav.is_empty()).override_failure_message( + "nav must be non-empty when on_open fires — base class ensures push_default" + ).is_false() + app.queue_free() + + +# ============================================================================= +# on_close — triggered via _internal_app_changed with GAMEPLAY +# ============================================================================= + + +func test_close_hides_app() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + assert_that(app.visible).is_true() # sanity + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.GAMEPLAY) + assert_that(app.visible).override_failure_message( + "GAMEPLAY mode must hide the app (on_close path)" + ).is_false() + app.queue_free() + + +func test_different_app_opened_closes_this_app() -> void: + # If a different app's path is broadcast, this app must close if visible. + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + assert_that(app.visible).is_true() + app._internal_app_changed("implant/other", HudGroups.Mode.FULLSCREEN) + assert_that(app.visible).override_failure_message( + "App must hide when a different app_path is activated" + ).is_false() + app.queue_free() + + +# ============================================================================= +# preserves_state +# ============================================================================= + + +func test_preserves_state_true_stack_survives_close_reopen() -> void: + var app = _make_app(true) + app.nav.set_default("home") + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) # open → push "home" + app.nav.push("details") # navigate deeper + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.GAMEPLAY) # close + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) # reopen + assert_that(app.nav.current()).override_failure_message( + "preserves_state=true: nav stack must survive close/reopen cycle" + ).is_equal("details") + app.queue_free() + + +func test_preserves_state_false_stack_reset_on_reopen() -> void: + var app = _make_app(false) + app.nav.set_default("home") + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) # open → reset → "home" + app.nav.push("details") # navigate deeper + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.GAMEPLAY) # close + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) # reopen → reset again + assert_that(app.nav.current()).override_failure_message( + "preserves_state=false: nav stack must reset to default on reopen" + ).is_equal("home") + app.queue_free() + + +# ============================================================================= +# on_insert_deactivated — gated on INSERT mode +# ============================================================================= + + +func test_on_insert_deactivated_closes_insert_app() -> void: + # Set HudGroups to INSERT mode for our test app so the method sees it. + HudGroups._active_app = TEST_APP_PATH + HudGroups._active_mode = HudGroups.Mode.INSERT + var app = _make_app(true, "insert") + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.INSERT) + assert_that(app.visible).is_true() + app.on_insert_deactivated() + # HudGroups.close_app() fires app_changed → GAMEPLAY → _internal_app_changed + assert_that(app.visible).override_failure_message( + "on_insert_deactivated must close INSERT-mode app" + ).is_false() + app.queue_free() + + +func test_on_insert_deactivated_does_not_close_fullscreen_app() -> void: + # FULLSCREEN apps are not affected by insert deactivation by default. + HudGroups._active_app = TEST_APP_PATH + HudGroups._active_mode = HudGroups.Mode.FULLSCREEN + var app = _make_app(true, "fullscreen") + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + assert_that(app.visible).is_true() + app.on_insert_deactivated() + assert_that(app.visible).override_failure_message( + "on_insert_deactivated must NOT close FULLSCREEN app by default" + ).is_true() + app.queue_free() + + +# ============================================================================= +# register_screen / current_screen_id +# ============================================================================= + + +func test_register_screen_adds_screen_as_child_hidden() -> void: + var app = _make_app() + var screen := Control.new() + app.register_screen("main", screen) + assert_that(screen.get_parent() == app).override_failure_message( + "register_screen must add screen as child of app" + ).is_true() + assert_that(screen.visible).override_failure_message( + "register_screen must start screen hidden" + ).is_false() + app.queue_free() + + +func test_nav_push_shows_registered_screen() -> void: + var app = _make_app() + var screen := Control.new() + app.register_screen("main", screen) + app.nav.push("main") + assert_that(screen.visible).override_failure_message( + "nav.push must make the registered screen visible via _on_screen_changed" + ).is_true() + app.queue_free() + + +func test_nav_push_hides_previous_screen() -> void: + var app = _make_app() + var screen_a := Control.new() + var screen_b := Control.new() + app.register_screen("a", screen_a) + app.register_screen("b", screen_b) + app.nav.push("a") + app.nav.push("b") + assert_that(screen_a.visible).override_failure_message( + "Previous screen must be hidden when new screen is pushed" + ).is_false() + assert_that(screen_b.visible).override_failure_message( + "New screen must be visible after push" + ).is_true() + app.queue_free() + + +func test_current_screen_id_tracks_nav() -> void: + var app = _make_app() + var screen := Control.new() + app.register_screen("main", screen) + app.nav.push("main") + assert_that(app.current_screen_id()).is_equal("main") + app.queue_free() + + +func test_register_screen_duplicate_id_does_not_overwrite() -> void: + var app = _make_app() + var screen_a := Control.new() + var screen_b := Control.new() + app.register_screen("main", screen_a) + app.register_screen("main", screen_b) # duplicate — must warn and skip + # First registration wins: _screens["main"] stays screen_a, screen_b is NOT + # parented by register_screen. (The base only mutates state on success.) + assert_that(app._screens["main"]).override_failure_message( + "First registered screen must win on duplicate id" + ).is_same(screen_a) + assert_that(screen_b.get_parent()).override_failure_message( + "Duplicate screen must not be reparented to the app" + ).is_null() + app.nav.push("main") + assert_that(screen_a.visible).override_failure_message( + "First registered screen must be visible after nav push" + ).is_true() + screen_b.queue_free() # not a child of app — free manually + app.queue_free() diff --git a/client/tests/test_implant_nav_stack.gd b/client/tests/test_implant_nav_stack.gd new file mode 100644 index 000000000..39ec4613d --- /dev/null +++ b/client/tests/test_implant_nav_stack.gd @@ -0,0 +1,246 @@ +class_name TestImplantNavStack +extends GdUnitTestSuite +## Unit tests for ImplantNavStack (#844, D-191, PR #131 item 5). +## Tests push/pop/replace/reset, signal emission, current state, and re-entrancy guard. +## All tests are synchronous — ImplantNavStack mutations are synchronous by design. + +# Untyped — class_name ImplantNavStack not yet registered at test-suite parse time. +var _nav = null +var _last_signal_id: String = "" +var _signal_count: int = 0 + + +func before_test() -> void: + var NavStack := load("res://ui/implant/implant_nav_stack.gd") + _nav = NavStack.new() + add_child(_nav) + _last_signal_id = "" + _signal_count = 0 + _nav.screen_changed.connect(_on_screen_changed) + + +func after_test() -> void: + _nav.queue_free() + _nav = null + + +func _on_screen_changed(id: String) -> void: + _last_signal_id = id + _signal_count += 1 + + +# ============================================================================= +# push / current / current_payload +# ============================================================================= + + +func test_push_sets_current() -> void: + _nav.push("alpha") + assert_that(_nav.current()).is_equal("alpha") + + +func test_push_stacks() -> void: + _nav.push("alpha") + _nav.push("beta") + assert_that(_nav.current()).is_equal("beta") + + +func test_push_emits_screen_changed() -> void: + _nav.push("alpha") + assert_that(_last_signal_id).is_equal("alpha") + assert_that(_signal_count).is_equal(1) + + +func test_push_with_payload_accessible_via_current_payload() -> void: + _nav.push("alpha", {"key": "val"}) + assert_that(_nav.current_payload().get("key", "")).is_equal("val") + + +func test_push_empty_payload_returns_empty_dict() -> void: + _nav.push("alpha") + assert_that(_nav.current_payload().is_empty()).is_true() + + +func test_push_payload_does_not_bleed_to_next_push() -> void: + _nav.push("alpha", {"key": "val"}) + _nav.push("beta") + assert_that(_nav.current_payload().is_empty()).is_true() + + +# ============================================================================= +# pop +# ============================================================================= + + +func test_pop_removes_top() -> void: + _nav.push("alpha") + _nav.push("beta") + _nav.pop() + assert_that(_nav.current()).is_equal("alpha") + + +func test_pop_emits_screen_changed_to_previous() -> void: + _nav.push("alpha") + _nav.push("beta") + _signal_count = 0 + _nav.pop() + assert_that(_last_signal_id).is_equal("alpha") + assert_that(_signal_count).is_equal(1) + + +func test_pop_to_empty_with_default_pushes_default() -> void: + # Stack-floor behaviour: pop() never leaves the stack empty when a default + # screen is set. Internally calls push(_default_screen_id). + _nav.set_default("home") + _nav.push("alpha") + _nav.pop() + assert_that(_nav.current()).is_equal("home") + + +func test_pop_to_empty_without_default_empties_stack() -> void: + _nav.push("alpha") + _nav.pop() + assert_that(_nav.is_empty()).is_true() + + +func test_pop_on_empty_stack_does_not_crash() -> void: + # Empty pop emits a warning and emits no signal. + _nav.pop() + assert_that(_signal_count).is_equal(0) + assert_that(_nav.is_empty()).is_true() + + +# ============================================================================= +# replace +# ============================================================================= + + +func test_replace_swaps_top() -> void: + _nav.push("alpha") + _nav.replace("beta") + assert_that(_nav.current()).is_equal("beta") + + +func test_replace_does_not_grow_stack() -> void: + _nav.push("alpha") + _nav.replace("beta") + # Stack has only "beta" — popping should empty it (no "alpha" below). + _nav.pop() + assert_that(_nav.is_empty()).is_true() + + +func test_replace_emits_screen_changed() -> void: + _nav.push("alpha") + _signal_count = 0 + _nav.replace("beta") + assert_that(_last_signal_id).is_equal("beta") + assert_that(_signal_count).is_equal(1) + + +func test_replace_on_empty_stack_acts_as_push() -> void: + _nav.replace("alpha") + assert_that(_nav.current()).is_equal("alpha") + + +func test_replace_updates_payload() -> void: + _nav.push("alpha", {"step": 1}) + _nav.replace("beta", {"step": 2}) + assert_that(_nav.current_payload().get("step", 0)).is_equal(2) + + +# ============================================================================= +# reset_to_default +# ============================================================================= + + +func test_reset_to_default_clears_stack_and_pushes_default() -> void: + _nav.set_default("home") + _nav.push("alpha") + _nav.push("beta") + _nav.reset_to_default() + assert_that(_nav.current()).is_equal("home") + + +func test_reset_to_default_without_default_empties_stack() -> void: + _nav.push("alpha") + _nav.push("beta") + _nav.reset_to_default() + assert_that(_nav.is_empty()).is_true() + + +# ============================================================================= +# push_default +# ============================================================================= + + +func test_push_default_pushes_the_default_id() -> void: + _nav.set_default("home") + _nav.push_default() + assert_that(_nav.current()).is_equal("home") + + +func test_push_default_is_noop_if_no_default() -> void: + _nav.push_default() + assert_that(_nav.is_empty()).is_true() + assert_that(_signal_count).is_equal(0) + + +# ============================================================================= +# is_empty +# ============================================================================= + + +func test_is_empty_true_at_start() -> void: + assert_that(_nav.is_empty()).is_true() + + +func test_is_empty_false_after_push() -> void: + _nav.push("alpha") + assert_that(_nav.is_empty()).is_false() + + +func test_is_empty_true_after_pop_to_bottom() -> void: + _nav.push("alpha") + _nav.pop() + assert_that(_nav.is_empty()).is_true() + + +# ============================================================================= +# Re-entrancy guard +# ============================================================================= + + +func test_nested_push_from_signal_handler_is_blocked() -> void: + # Pushing from within a screen_changed handler must be rejected and must not + # corrupt the stack. + var nested_count := 0 + _nav.screen_changed.connect( + func(_id: String) -> void: + nested_count += 1 + if nested_count == 1: + _nav.push("nested") # must be blocked by _mutating guard + ) + _nav.push("alpha") + # "nested" must NOT have been pushed — stack top is "alpha". + assert_that(_nav.current()).is_equal("alpha") + + +func test_pop_internal_push_of_default_is_not_blocked() -> void: + # pop() internally calls push(default) when the stack empties. This internal + # call must NOT be blocked by the re-entrancy guard. + _nav.set_default("home") + _nav.push("alpha") + _nav.pop() # pop alpha → empty → internal push("home") + assert_that(_nav.current()).is_equal("home") + + +func test_replace_from_signal_handler_is_blocked() -> void: + var nested_count := 0 + _nav.screen_changed.connect( + func(_id: String) -> void: + nested_count += 1 + if nested_count == 1: + _nav.replace("nested") # must be blocked + ) + _nav.push("alpha") + assert_that(_nav.current()).is_equal("alpha") diff --git a/client/tests/test_implant_registry.gd b/client/tests/test_implant_registry.gd new file mode 100644 index 000000000..697518e41 --- /dev/null +++ b/client/tests/test_implant_registry.gd @@ -0,0 +1,209 @@ +class_name TestImplantRegistry +extends GdUnitTestSuite +## Unit tests for ImplantRegistry autoload (#844, D-191, PR #131 item 5). +## Tests lazy-scan, manifest validation, mode resolution, real-scan results. + +# Loaded inside method bodies to avoid class_name parse-order trap. +const MANIFEST_SCRIPT := "res://ui/implant/implant_app_manifest.gd" + + +func _make_manifest(app_path: String, mode: String = "fullscreen", key: int = -1) -> Resource: + var ManifestClass := load(MANIFEST_SCRIPT) + var m = ManifestClass.new() + m.app_path = app_path + m.default_mode = mode + m.default_key = key + m.schema_version = 1 + return m + + +func before_test() -> void: + # Reset to a clean slate so each test gets a fresh scan pass. + ImplantRegistry._scanned = false + ImplantRegistry._manifests.clear() + ImplantRegistry._resolved_modes.clear() + ImplantRegistry._instances.clear() + + +func after_test() -> void: + ImplantRegistry._scanned = false + ImplantRegistry._manifests.clear() + ImplantRegistry._resolved_modes.clear() + ImplantRegistry._instances.clear() + + +# ============================================================================= +# _is_valid_manifest — white-box validation helper +# ============================================================================= + + +func test_invalid_manifest_null_returns_false() -> void: + assert_that(ImplantRegistry._is_valid_manifest(null)).is_false() + + +func test_invalid_manifest_string_returns_false() -> void: + assert_that(ImplantRegistry._is_valid_manifest("not a resource")).is_false() + + +func test_invalid_manifest_plain_resource_no_app_path_returns_false() -> void: + # A bare Resource has no app_path property. + assert_that(ImplantRegistry._is_valid_manifest(Resource.new())).is_false() + + +func test_invalid_manifest_empty_app_path_returns_false() -> void: + var m = _make_manifest("") + assert_that(ImplantRegistry._is_valid_manifest(m)).is_false() + + +func test_valid_manifest_with_app_path_returns_true() -> void: + var m = _make_manifest("implant/test") + assert_that(ImplantRegistry._is_valid_manifest(m)).is_true() + + +# ============================================================================= +# Lazy scan +# ============================================================================= + + +func test_scanned_false_before_any_call() -> void: + assert_that(ImplantRegistry._scanned).is_false() + + +func test_get_manifests_triggers_scan() -> void: + assert_that(ImplantRegistry._scanned).is_false() + ImplantRegistry.get_manifests() + assert_that(ImplantRegistry._scanned).is_true() + + +func test_get_resolved_mode_triggers_scan() -> void: + assert_that(ImplantRegistry._scanned).is_false() + ImplantRegistry.get_resolved_mode("implant/map") + assert_that(ImplantRegistry._scanned).is_true() + + +func test_second_get_manifests_uses_cache() -> void: + # _scanned = true after first call; subsequent calls must not reset it. + ImplantRegistry.get_manifests() + assert_that(ImplantRegistry._scanned).is_true() + ImplantRegistry.get_manifests() + assert_that(ImplantRegistry._scanned).is_true() + + +# ============================================================================= +# Real-filesystem scan — verifies atlas and economics apps are found +# ============================================================================= + + +func test_real_scan_finds_atlas_app() -> void: + var manifests := ImplantRegistry.get_manifests() + var found := false + for m in manifests: + if m.app_path == "implant/map": + found = true + break + assert_that(found).override_failure_message( + "ImplantRegistry must find atlas app (implant/map) after scan" + ).is_true() + + +func test_real_scan_finds_economics_app() -> void: + var manifests := ImplantRegistry.get_manifests() + var found := false + for m in manifests: + if m.app_path == "implant/economics": + found = true + break + assert_that(found).override_failure_message( + "ImplantRegistry must find economics app (implant/economics) after scan" + ).is_true() + + +func test_real_scan_manifests_all_have_schema_version_1() -> void: + var manifests := ImplantRegistry.get_manifests() + for m in manifests: + assert_that(m.schema_version).override_failure_message( + "All shipped manifests must declare schema_version = 1 — got wrong version for %s" % m.app_path + ).is_equal(1) + + +func test_real_scan_no_duplicate_keys() -> void: + # Key collision detection: _scan() must drop the second manifest if two + # declare the same default_key. Verify no duplicates survive in the result. + var manifests := ImplantRegistry.get_manifests() + var seen_keys: Dictionary = {} + for m in manifests: + var k: int = m.default_key + if k >= 0: + assert_that(not seen_keys.has(k)).override_failure_message( + "Key %d bound to both '%s' and '%s' — collision not detected by registry" % [ + k, seen_keys.get(k, ""), m.app_path + ] + ).is_true() + seen_keys[k] = m.app_path + + +# ============================================================================= +# get_resolved_mode +# ============================================================================= + + +func test_get_resolved_mode_atlas_is_fullscreen() -> void: + assert_that(ImplantRegistry.get_resolved_mode("implant/map")).is_equal(HudGroups.Mode.FULLSCREEN) + + +func test_get_resolved_mode_economics_is_insert() -> void: + assert_that(ImplantRegistry.get_resolved_mode("implant/economics")).is_equal(HudGroups.Mode.INSERT) + + +func test_get_resolved_mode_unknown_path_defaults_to_fullscreen() -> void: + ImplantRegistry.get_manifests() # ensure scan ran + assert_that(ImplantRegistry.get_resolved_mode("implant/nonexistent")).is_equal(HudGroups.Mode.FULLSCREEN) + + +# ============================================================================= +# get_app_instance — before instantiate_all +# ============================================================================= + + +func test_get_app_instance_returns_null_before_instantiate_all() -> void: + # Registry scans manifests but does not instantiate until instantiate_all() + # is called from hud.gd. Querying before that must return null. + ImplantRegistry.get_manifests() + assert_that(ImplantRegistry.get_app_instance("implant/map")).is_null() + + +# ============================================================================= +# _MODE_MAP — covers the invalid default_mode guard in _scan() +# ============================================================================= + + +func test_mode_map_contains_all_valid_mode_strings() -> void: + assert_that(ImplantRegistry._MODE_MAP.has("gameplay")).is_true() + assert_that(ImplantRegistry._MODE_MAP.has("insert")).is_true() + assert_that(ImplantRegistry._MODE_MAP.has("fullscreen")).is_true() + + +func test_mode_map_does_not_contain_invalid_strings() -> void: + # _scan() rejects manifests whose default_mode is not in this map. + assert_that(ImplantRegistry._MODE_MAP.has("bogus")).is_false() + assert_that(ImplantRegistry._MODE_MAP.has("")).is_false() + assert_that(ImplantRegistry._MODE_MAP.has("Fullscreen")).is_false() # case-sensitive + + +func test_real_scan_manifests_have_valid_mode_strings() -> void: + var manifests := ImplantRegistry.get_manifests() + for m in manifests: + assert_that(ImplantRegistry._MODE_MAP.has(m.default_mode)).override_failure_message( + "Manifest %s has invalid default_mode '%s' — _scan should have rejected it" % [ + m.app_path, m.default_mode + ] + ).is_true() + + +# ============================================================================= +# Schema version constant +# ============================================================================= + + +func test_current_schema_version_is_1() -> void: + assert_that(ImplantRegistry.CURRENT_SCHEMA_VERSION).is_equal(1)