Files
settled-reach/client/tests/test_implant_app_lifecycle.gd
T
jpmschweitzerandClaude Opus 4.6 a8321702c3 test(ui): PR #131 review — implant primitive tests (item 5)
Three test suites covering ImplantNavStack, ImplantRegistry, and
ImplantApp lifecycle:

- test_implant_nav_stack.gd: 26 tests — push/pop/replace/reset,
  push_default, is_empty, current/current_payload, signal emission,
  re-entrancy guard, stack-floor-to-default-on-last-pop.
- test_implant_registry.gd: 21 tests — _is_valid_manifest validation,
  lazy scan flag, get_manifests / get_resolved_mode both trigger scan,
  cache on second call, real scan finds atlas and economics, no
  duplicate keys, _MODE_MAP coverage, get_app_instance null before
  instantiate_all, CURRENT_SCHEMA_VERSION = 1.
- test_implant_app_lifecycle.gd: 18 tests — nav created in _ready,
  starts hidden, open FULLSCREEN/INSERT makes visible, GAMEPLAY/wrong
  path doesn't open, nav non-empty on open, close hides, app-switching
  closes active, preserves_state true/false, on_insert_deactivated
  gated on INSERT (closes) vs FULLSCREEN (no-op), register_screen
  adds hidden child, duplicate id does not overwrite (first-wins).

Two team-lead fix-ups before commit (Stig caught the class_name
parse-order issue but used the wrong gdUnit4 hook names):
- before_each/after_each renamed to before_test/after_test per
  gdUnit4 API. test_game_state.gd's use of before_each appears to
  work by coincidence (that test resets autoload state rather than
  constructing objects, so the never-called hook didn't matter);
  tests that rely on hook-driven setup need the correct names.
- test_register_screen_duplicate_id_does_not_overwrite rewritten to
  assert the actual contract (first-wins on _screens dict +
  duplicate screen is not reparented) instead of Control.visible
  default, which defaults to true regardless of registration.

Also removed a stray client/ui/implant/apps/collision_test/app.tres
fixture left over from Hoshe's earlier manual collision-warning
verification. It was untracked and would have blocked atlas from
registering at runtime (KEY_M collision, collision_test won the scan
order). Not committing it.

All three suites exit 0, totals 26/26, 42/42, 36/36 passed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 15:36:04 +02:00

282 lines
10 KiB
GDScript

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()