feat(ui): AtlasAgentInterface — observe/act named-intent control channel (D-226, T-971)
D-226 layer 4, rebuilt against the post-D-255 stepped Atlas after the Phase-1 reconciliation (the original intent list targeted the retired continuous-zoom viewer). Eleven intents, each backed by the exact production handler a click calls — select/open for systems and bodies (extracted shared by-id tails so click and intent paths are one code path), scroll_rung, reset_view, back, open/close_atlas, set_overlay — plus two new first-class capabilities: jump_to_center (the fixed-center revisit pattern proven by five eyeball drivers, via a new StepCanvasViewer.jump_to seam that reuses _scroll_rung's exact request tail — same extent cap, same cache keys) and get_current_canvas_summary (allocation-light reads off the raw wire dict, courses-by-class, draw-matched settlement dedup — no PNG decode). Contract shape: dumb AtlasAgentBridge autoload holding the app handle (untyped per the parse-order rule), all logic in the static AtlasAgentInterface class. observe() is side-effect-free: current state + a generic Control-walk affordance tree. In-process consumers only this ticket (documented); the committed reference driver (atlas_agent_driver.gd, InputSwallower + settle-until-ready from the T-1157 inventory) replaces the scratch eyeball drivers as the sanctioned headless-drive pattern. select_city/open_regional dropped with recorded rationale (no settlement hit-test affordance exists post-D-255) — diff on the ticket. 33 new tests across three suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
## Reference in-process Atlas agent driver (T-971, D-226 item 4) — the
|
||||
## SANCTIONED way to drive the Atlas headlessly via AtlasAgentInterface,
|
||||
## replacing the five hand-rolled scratch eyeball drivers the T-1183/T-1157
|
||||
## harness rounds produced ad hoc. Every real navigation call below goes
|
||||
## through AtlasAgentInterface.act()/observe() — this file does not touch
|
||||
## `_gui_input`, does not synthesize pixel events, and does not reach into
|
||||
## any screen's private fields.
|
||||
##
|
||||
## Folds in two of the T-1157 harness-techniques inventory patterns
|
||||
## (pql ticket show T-1157):
|
||||
## 1. InputSwallower — a root-level Node, added first, low process_priority,
|
||||
## whose _input()/_unhandled_input() both call
|
||||
## get_viewport().set_input_as_handled() unconditionally. Neutralizes
|
||||
## real desktop input reaching this `-s` SceneTree driver's window
|
||||
## (X11/XWayland can deliver pointer events to an unfocused window under
|
||||
## the cursor on a shared desktop) — this driver runs real production
|
||||
## code via method calls, but the WINDOW still exists and can still
|
||||
## receive stray input, so the guard applies here too.
|
||||
## 4. Full-run-restart-on-anomaly (documented, not code-enforced — see
|
||||
## _run() header note below): any assertion failure here should discard
|
||||
## the whole run rather than retrying mid-sequence, matching the
|
||||
## inventory's "partial retries silently corrupt the state the
|
||||
## comparison depends on" finding. This driver's own job list is short
|
||||
## and idempotent per invocation (fresh process each run), so the
|
||||
## discipline is "don't loop-retry a failed step in place" rather than a
|
||||
## literal restart mechanism.
|
||||
##
|
||||
## Technique 5 (fixed-center revisit) is what `jump_to_center` (this file's
|
||||
## own `_run_job("jump_to_center", ...)` dispatch) exists to exercise —
|
||||
## AtlasAgentInterface's production seam for it, not reimplemented here.
|
||||
##
|
||||
## Usage (JSON job list on stdin path, matching visual_capture.gd's own
|
||||
## `-- --flag value` CLI convention):
|
||||
## godot --rendering-driver opengl3 --path client \
|
||||
## -s res://tests/atlas_agent_driver.gd -- \
|
||||
## --jobs res://tests/fixtures/atlas_agent_smoke_jobs.json \
|
||||
## --output /abs/path/observe_log.json
|
||||
##
|
||||
## Each job is {"intent": "...", "params": {...}}; "observe" is a
|
||||
## pseudo-intent this driver special-cases to call
|
||||
## AtlasAgentInterface.observe() instead of act() (observe takes no params).
|
||||
## The full sequence of act()/observe() results is written to --output as a
|
||||
## JSON array — the smoke test (test_atlas_agent_driver_smoke.gd) reads this
|
||||
## back to assert the reference driver actually completes a real session.
|
||||
extends SceneTree
|
||||
|
||||
var _jobs_path: String = ""
|
||||
var _output_path: String = ""
|
||||
var _results: Array = []
|
||||
|
||||
|
||||
func _init():
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_parse_args()
|
||||
|
||||
if _jobs_path.is_empty():
|
||||
push_error("atlas_agent_driver: --jobs PATH is required")
|
||||
quit(1)
|
||||
return
|
||||
if _output_path.is_empty():
|
||||
push_error("atlas_agent_driver: --output PATH is required")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var jobs: Array = _load_jobs(_jobs_path)
|
||||
if jobs.is_empty():
|
||||
push_error("atlas_agent_driver: no jobs loaded from %s" % _jobs_path)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var main_scene = load("res://scenes/main.tscn")
|
||||
var main_node = main_scene.instantiate()
|
||||
|
||||
# Technique 1 (T-1157 inventory) — InputSwallower, added FIRST so its low
|
||||
# process_priority still runs before anything that might otherwise react
|
||||
# to stray desktop input reaching this unfocused/off-screen window.
|
||||
var swallower := _InputSwallower.new()
|
||||
swallower.process_priority = -1000
|
||||
root.add_child(swallower)
|
||||
|
||||
root.add_child(main_node)
|
||||
|
||||
# Settle frames — camera smoothing, fog uniform init, UI layout (same
|
||||
# rationale as visual_capture.gd's own settle-frame pass).
|
||||
for i in range(10):
|
||||
await process_frame
|
||||
|
||||
var atlas_agent_interface := load("res://ui/implant/apps/atlas/atlas_agent_interface.gd")
|
||||
var atlas_agent_bridge: Node = root.get_node("/root/AtlasAgentBridge")
|
||||
|
||||
for job: Dictionary in jobs:
|
||||
var intent: String = str(job.get("intent", ""))
|
||||
var params: Dictionary = job.get("params", {})
|
||||
var result: Dictionary
|
||||
if intent == "observe":
|
||||
result = atlas_agent_interface.observe(atlas_agent_bridge.current_app)
|
||||
else:
|
||||
result = atlas_agent_interface.act(atlas_agent_bridge.current_app, intent, params)
|
||||
# Settle-until-ready (T-1157 inventory item 2's underlying discipline,
|
||||
# applied generically here rather than per-navigation-intent): a few
|
||||
# frames after every mutating call so the resulting screen/viewer
|
||||
# state (panel rebuilds, queue_redraw()) has actually applied before
|
||||
# the NEXT job's observe() reads it.
|
||||
for i in range(4):
|
||||
await process_frame
|
||||
_results.append({"intent": intent, "params": params, "result": result})
|
||||
|
||||
_write_output(_output_path, _results)
|
||||
quit()
|
||||
|
||||
|
||||
func _parse_args() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
match args[i]:
|
||||
"--jobs":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
_jobs_path = args[i]
|
||||
"--output":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
_output_path = args[i]
|
||||
i += 1
|
||||
|
||||
|
||||
static func _load_jobs(path: String) -> Array:
|
||||
var abs_path: String = path
|
||||
if path.begins_with("res://"):
|
||||
abs_path = ProjectSettings.globalize_path(path)
|
||||
var f := FileAccess.open(abs_path, FileAccess.READ)
|
||||
if f == null:
|
||||
return []
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
return parsed if parsed is Array else []
|
||||
|
||||
|
||||
static func _write_output(path: String, results: Array) -> void:
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_error("atlas_agent_driver: cannot open output path %s" % path)
|
||||
return
|
||||
f.store_string(JSON.stringify(results, " "))
|
||||
f.close()
|
||||
|
||||
|
||||
## T-1157 inventory item 1 — see this file's own header doc for the full
|
||||
## rationale. A tiny inner class rather than a separate file: this pattern
|
||||
## has exactly one consumer (this driver) today: promote to a shared
|
||||
## `client/tests/` helper the moment a second `-s` driver needs it.
|
||||
class _InputSwallower:
|
||||
extends Node
|
||||
|
||||
func _input(_event: InputEvent) -> void:
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _unhandled_input(_event: InputEvent) -> void:
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"intent": "open_atlas", "params": {}},
|
||||
{"intent": "observe", "params": {}},
|
||||
{"intent": "close_atlas", "params": {}},
|
||||
{"intent": "observe", "params": {}}
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
## T-971 smoke test for atlas_agent_driver.gd — the reference `-s` SceneTree
|
||||
## driver for AtlasAgentInterface. A gdUnit suite cannot boot a SECOND full
|
||||
## Godot process (the driver's real usage mode: `godot -s
|
||||
## res://tests/atlas_agent_driver.gd -- --jobs ... --output ...`) without
|
||||
## spawning a nested engine instance, which is slow/fragile and inconsistent
|
||||
## with how this project already runs its OTHER `-s` drivers
|
||||
## (visual_capture.gd/atlas_shots.json — manual `make` targets, never inside
|
||||
## the gdUnit suite tests/run-godot invokes). The feasible headless smoke
|
||||
## here is the driver's own PURE helper logic: job-file loading and
|
||||
## result-array writing, both `static func`s with no SceneTree dependency —
|
||||
## exercising the exact code path a real run's job-list parsing and
|
||||
## output-writing go through, without booting a second engine.
|
||||
class_name TestAtlasAgentDriver
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const DriverScript := preload("res://tests/atlas_agent_driver.gd")
|
||||
|
||||
var _tmp_dir: String = ""
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
_tmp_dir = "user://test_atlas_agent_driver/%d/" % Time.get_ticks_usec()
|
||||
DirAccess.make_dir_recursive_absolute(_tmp_dir)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
var d := DirAccess.open(_tmp_dir)
|
||||
if d == null:
|
||||
return
|
||||
for f: String in d.get_files():
|
||||
d.remove(f)
|
||||
DirAccess.remove_absolute(_tmp_dir.rstrip("/"))
|
||||
|
||||
|
||||
func test_load_jobs_reads_the_shipped_smoke_fixture() -> void:
|
||||
var jobs: Array = DriverScript._load_jobs(
|
||||
"res://tests/fixtures/atlas_agent_smoke_jobs.json"
|
||||
)
|
||||
assert_int(jobs.size()).is_equal(4)
|
||||
assert_str((jobs[0] as Dictionary).get("intent", "")).is_equal("open_atlas")
|
||||
assert_str((jobs[1] as Dictionary).get("intent", "")).is_equal("observe")
|
||||
|
||||
|
||||
func test_load_jobs_returns_empty_array_for_a_missing_file() -> void:
|
||||
var jobs: Array = DriverScript._load_jobs("res://tests/fixtures/does_not_exist.json")
|
||||
assert_int(jobs.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_write_output_round_trips_through_json() -> void:
|
||||
var out_path: String = _tmp_dir + "results.json"
|
||||
var results: Array = [
|
||||
{"intent": "observe", "params": {}, "result": {"screen": "reach"}},
|
||||
{"intent": "select_system", "params": {"system_id": "GJ1"}, "result": {"ok": true}},
|
||||
]
|
||||
|
||||
DriverScript._write_output(out_path, results)
|
||||
|
||||
var f := FileAccess.open(out_path, FileAccess.READ)
|
||||
assert_object(f).is_not_null()
|
||||
var parsed: Variant = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
assert_that(parsed).is_equal(results)
|
||||
|
||||
|
||||
## The InputSwallower inner class (T-1157 inventory item 1) — a plain Node
|
||||
## subclass with no SceneTree/window dependency for its OWN logic
|
||||
## (set_input_as_handled() requires a live viewport to call meaningfully, but
|
||||
## the class must at least instantiate and expose the two input hooks by
|
||||
## name — this is the structural smoke check; the swallowing BEHAVIOR itself
|
||||
## is exercised implicitly every time atlas_agent_driver.gd actually runs,
|
||||
## per its own header doc).
|
||||
func test_input_swallower_inner_class_instantiates_and_has_both_input_hooks() -> void:
|
||||
# A nested `class X: extends Y` inside a script is exposed as a
|
||||
# script-level constant on the OUTER GDScript resource — reachable via
|
||||
# plain dot access on the preloaded DriverScript, same as any other
|
||||
# nested-class reference in this codebase's own suites (e.g.
|
||||
# GdUnitObjectAssertImplTest.gd's MyNode/MyExtendedNode pattern), just
|
||||
# via a loaded resource instead of a same-file bare identifier.
|
||||
var instance: Node = DriverScript._InputSwallower.new()
|
||||
assert_bool(instance.has_method("_input")).is_true()
|
||||
assert_bool(instance.has_method("_unhandled_input")).is_true()
|
||||
instance.free()
|
||||
@@ -0,0 +1,336 @@
|
||||
## T-971 tests: AtlasAgentInterface — the observe/act named-intent channel.
|
||||
## Every act() test asserts the intent reaches the SAME production state
|
||||
## mutation the real click handler performs (spy at the handler seam: read
|
||||
## the resulting screen/viewer state back, exactly as a real click's caller
|
||||
## would observe it), never a synthesized _gui_input event. test_mode
|
||||
## (SimBridge default outside SR_LIVE=1) means request_step_canvas() is a
|
||||
## silent no-op — these tests exercise client-side state only, no live
|
||||
## server needed, matching test_step_canvas_viewer.gd's own precedent.
|
||||
class_name TestAtlasAgentInterface
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasAgentInterfaceScript := preload("res://ui/implant/apps/atlas/atlas_agent_interface.gd")
|
||||
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
|
||||
|
||||
## AtlasApp._ready() unconditionally reloads the real app.tres manifest
|
||||
## (`manifest = load("res://ui/implant/apps/atlas/app.tres")`), overwriting
|
||||
## anything set before add_child() — so, unlike the generic ImplantApp
|
||||
## lifecycle tests (which use a bare, manifest-injectable ImplantApp), a real
|
||||
## AtlasApp instance ALWAYS registers under the production app_path. Tests
|
||||
## drive HudGroups with THIS path, not a synthetic test-only one.
|
||||
const TEST_APP_PATH := "implant/map"
|
||||
|
||||
const FIXTURE_SYSTEM := {
|
||||
"system_id": "GJ1",
|
||||
"proper_name": "Gliese J1",
|
||||
"hop_distance": 0,
|
||||
"orbit_bodies": [
|
||||
{
|
||||
"body_id": "GJ1b", "proper_name": "Barrenholt", "body_type": "planet",
|
||||
"orbit_index": 0, "terrain_reference": "GJ1b_heightmap",
|
||||
},
|
||||
{
|
||||
"body_id": "GJ1c", "proper_name": "Cindral", "body_type": "planet",
|
||||
"orbit_index": 1, "terrain_reference": null,
|
||||
},
|
||||
],
|
||||
"stations": [],
|
||||
}
|
||||
|
||||
|
||||
## Returns an AtlasApp instance (untyped — matches _make_app()'s own
|
||||
## precedent in test_implant_app_lifecycle.gd, which keeps the return
|
||||
## untyped there too for the same class_name parse-order reason). No manifest
|
||||
## injection needed (unlike the generic ImplantApp lifecycle tests) —
|
||||
## AtlasApp._ready() always loads the real production manifest itself.
|
||||
func _make_app():
|
||||
var AppClass := load("res://ui/implant/apps/atlas/atlas_app.gd")
|
||||
var app = AppClass.new()
|
||||
add_child(app) # fires _ready() -> on_install()
|
||||
return app
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
HudGroups._active_app = ""
|
||||
HudGroups._active_mode = HudGroups.Mode.GAMEPLAY
|
||||
HudGroups._groups.erase(TEST_APP_PATH)
|
||||
HudGroups._groups.erase("implant/map")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# observe()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_observe_reports_current_screen_id() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.observe(app)
|
||||
assert_str(result.get("screen", "")).is_equal("reach")
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_observe_returns_error_for_null_app() -> void:
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.observe(null)
|
||||
assert_bool(result.has("error")).is_true()
|
||||
|
||||
|
||||
func test_observe_affordance_tree_includes_overlay_bar_buttons_on_regional() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
app.nav.push("regional", {"body": {"body_id": "GJ1b"}, "system": FIXTURE_SYSTEM})
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.observe(app)
|
||||
var affordances: Array = result.get("affordances", [])
|
||||
assert_int(affordances.size()).is_greater(0)
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_observe_is_side_effect_free() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
var before: String = app.current_screen_id()
|
||||
AtlasAgentInterfaceScript.observe(app)
|
||||
assert_str(app.current_screen_id()).is_equal(before)
|
||||
app.queue_free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# act() — reach screen intents
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_select_system_reaches_reach_screen_selection_state() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
app.get_screen("reach").set_systems([FIXTURE_SYSTEM], {"GJ1": FIXTURE_SYSTEM})
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.act(app, "select_system", {"system_id": "GJ1"})
|
||||
|
||||
assert_bool(result.get("ok", false)).is_true()
|
||||
assert_bool(app.get_screen("reach").has_selection()).is_true()
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_open_system_reaches_atlas_app_nav_push() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
app.get_screen("reach").set_systems([FIXTURE_SYSTEM], {"GJ1": FIXTURE_SYSTEM})
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.act(app, "open_system", {"system_id": "GJ1"})
|
||||
|
||||
assert_bool(result.get("ok", false)).is_true()
|
||||
assert_str(app.current_screen_id()).is_equal("system")
|
||||
app.queue_free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# act() — system screen intents
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _enter_orbital(app) -> void:
|
||||
app.get_screen("system").set_systems([FIXTURE_SYSTEM])
|
||||
app.nav.push("system", {"mode": "orbital", "system": FIXTURE_SYSTEM})
|
||||
|
||||
|
||||
func test_select_body_reaches_system_screen_body_panel_state() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_orbital(app)
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.act(app, "select_body", {"body_id": "GJ1c"})
|
||||
|
||||
assert_bool(result.get("ok", false)).is_true()
|
||||
assert_bool(app.get_screen("system").has_body_panel_open()).is_true()
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_open_body_reaches_atlas_app_nav_push_to_regional() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_orbital(app)
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.act(app, "open_body", {"body_id": "GJ1b"})
|
||||
|
||||
assert_bool(result.get("ok", false)).is_true()
|
||||
assert_str(app.current_screen_id()).is_equal("regional")
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_open_body_is_a_no_op_for_unrecognized_id() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_orbital(app)
|
||||
|
||||
AtlasAgentInterfaceScript.act(app, "open_body", {"body_id": "does_not_exist"})
|
||||
|
||||
assert_str(app.current_screen_id()).is_equal("system")
|
||||
app.queue_free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# act() — regional/step-canvas intents
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _enter_regional(app) -> void:
|
||||
app.nav.push("regional", {"body": {"body_id": "GJ1b", "body_radius_km": 6371.0}, "system": FIXTURE_SYSTEM})
|
||||
|
||||
|
||||
func test_scroll_rung_reaches_step_canvas_viewer_rung_transport() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_regional(app)
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.act(app, "scroll_rung", {"direction": 1})
|
||||
|
||||
assert_bool(result.get("ok", false)).is_true()
|
||||
assert_str(result.get("rung", "")).is_equal(StepCanvasTransport.RUNG_REGION)
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_reset_view_reaches_step_canvas_viewer_reset() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_regional(app)
|
||||
AtlasAgentInterfaceScript.act(app, "scroll_rung", {"direction": 1})
|
||||
AtlasAgentInterfaceScript.act(app, "scroll_rung", {"direction": 1})
|
||||
|
||||
AtlasAgentInterfaceScript.act(app, "reset_view", {})
|
||||
|
||||
var viewer: Variant = app.get_screen("regional").get_viewer()
|
||||
assert_str(viewer.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_set_overlay_reaches_step_canvas_viewer_overlay_state() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_regional(app)
|
||||
|
||||
AtlasAgentInterfaceScript.act(
|
||||
app, "set_overlay", {"overlay_id": "gen_dw_temp", "visible": true}
|
||||
)
|
||||
|
||||
var viewer: Variant = app.get_screen("regional").get_viewer()
|
||||
assert_bool(viewer.is_overlay_visible("gen_dw_temp")).is_true()
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_back_reaches_nav_pop() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_orbital(app)
|
||||
|
||||
AtlasAgentInterfaceScript.act(app, "back", {})
|
||||
|
||||
assert_str(app.current_screen_id()).is_equal("reach")
|
||||
app.queue_free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# jump_to_center — key-discipline test (T-971 highest-value intent)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The core guarantee this intent exists for: jumping to a center and
|
||||
## scrolling to the SAME center must produce the SAME request key (same
|
||||
## body/rung/center/extent) — jump_to() must go through the identical
|
||||
## _fire_request()/_request_extent() path, never a parallel one. Verified via
|
||||
## the request object's own held state (StepCanvasRequest's private _center/
|
||||
## _rung fields aren't public, so this compares the PUBLIC echo surface:
|
||||
## held_rung + world_center, which is exactly what a cache-key comparison
|
||||
## downstream would use).
|
||||
func test_jump_to_center_produces_the_same_request_key_as_an_equivalent_scroll() -> void:
|
||||
var app_a = _make_app()
|
||||
app_a._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_regional(app_a)
|
||||
var viewer_a: Variant = app_a.get_screen("regional").get_viewer()
|
||||
viewer_a._scroll_rung(1, Vector2(400.0, 300.0))
|
||||
var scrolled_center: Vector2 = viewer_a.get_world_center()
|
||||
var scrolled_rung: String = viewer_a.get_held_rung()
|
||||
app_a.queue_free()
|
||||
|
||||
var app_b = _make_app()
|
||||
app_b._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_regional(app_b)
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.act(
|
||||
app_b,
|
||||
"jump_to_center",
|
||||
{"world_center": [scrolled_center.x, scrolled_center.y], "rung": scrolled_rung}
|
||||
)
|
||||
|
||||
assert_bool(result.get("ok", false)).is_true()
|
||||
var viewer_b: Variant = app_b.get_screen("regional").get_viewer()
|
||||
assert_str(viewer_b.get_held_rung()).is_equal(scrolled_rung)
|
||||
assert_that(viewer_b.get_world_center()).is_equal(scrolled_center)
|
||||
app_b.queue_free()
|
||||
|
||||
|
||||
func test_jump_to_center_requires_world_center_param() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_regional(app)
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.act(app, "jump_to_center", {})
|
||||
|
||||
assert_bool(result.get("ok", false)).is_false()
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_jump_to_center_keeps_current_rung_when_rung_param_omitted() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
_enter_regional(app)
|
||||
var viewer: Variant = app.get_screen("regional").get_viewer()
|
||||
viewer._scroll_rung(1, Vector2(400.0, 300.0)) # now at Region
|
||||
|
||||
AtlasAgentInterfaceScript.act(app, "jump_to_center", {"world_center": [1000.0, 2000.0]})
|
||||
|
||||
assert_str(viewer.get_held_rung()).is_equal(StepCanvasTransport.RUNG_REGION)
|
||||
app.queue_free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# open_atlas / close_atlas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_open_atlas_reaches_hud_groups_open_app() -> void:
|
||||
var app = _make_app()
|
||||
# AtlasApp._ready() already registers itself under "implant/map" via
|
||||
# HudGroups.register() (see ImplantApp._ready()) — no manual registration
|
||||
# needed here, unlike a bare ImplantApp fixture.
|
||||
|
||||
AtlasAgentInterfaceScript.act(app, "open_atlas", {})
|
||||
|
||||
assert_bool(HudGroups.is_app_active("implant/map")).is_true()
|
||||
app.queue_free()
|
||||
|
||||
|
||||
func test_close_atlas_reaches_hud_groups_close_app() -> void:
|
||||
var app = _make_app()
|
||||
HudGroups.open_app("implant/map")
|
||||
|
||||
AtlasAgentInterfaceScript.act(app, "close_atlas", {})
|
||||
|
||||
assert_bool(HudGroups.is_app_active("implant/map")).is_false()
|
||||
app.queue_free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Unknown intent
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_unknown_intent_returns_ok_false_with_error() -> void:
|
||||
var app = _make_app()
|
||||
app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
|
||||
|
||||
var result: Dictionary = AtlasAgentInterfaceScript.act(app, "not_a_real_intent", {})
|
||||
|
||||
assert_bool(result.get("ok", true)).is_false()
|
||||
assert_bool(result.has("error")).is_true()
|
||||
app.queue_free()
|
||||
@@ -706,3 +706,116 @@ func test_global_canvas_scale_never_drops_below_native_on_a_narrow_viewport() ->
|
||||
"the effective pixels-per-gridunit ratio must floor at 1 (native),"
|
||||
+ " never a sub-1x downscale, even on a viewport this narrow"
|
||||
).is_equal_approx(1.0, 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-971 (AtlasAgentInterface): jump_to() — the fixed-center revisit seam —
|
||||
# and get_current_canvas_summary().
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## jump_to() must set the SAME held rung/world_center a cursor-anchored
|
||||
## scroll to that same spot would land on — this is the "same cache key"
|
||||
## guarantee AtlasAgentInterface's jump_to_center intent depends on
|
||||
## (verified end-to-end, through act(), in test_atlas_agent_interface.gd;
|
||||
## this is the narrower unit-level check directly against the viewer).
|
||||
func test_jump_to_sets_held_rung_and_world_center() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
|
||||
v.jump_to(Vector2(500.0, -250.0), StepCanvasTransport.RUNG_QUARTER)
|
||||
|
||||
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_QUARTER)
|
||||
assert_that(v.get_world_center()).is_equal(Vector2(500.0, -250.0))
|
||||
|
||||
|
||||
## Omitting `rung` keeps whatever rung is currently held — the "revisit
|
||||
## within the same rung" common case shouldn't require repeating it.
|
||||
func test_jump_to_keeps_current_rung_when_omitted() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(1, Vector2(400.0, 300.0)) # Region
|
||||
|
||||
v.jump_to(Vector2(10.0, 20.0))
|
||||
|
||||
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_REGION)
|
||||
|
||||
|
||||
## Jumping to Global always forces world_center to ZERO — Global has no
|
||||
## panned-center concept (mirrors _scroll_rung()'s own Global-rung handling).
|
||||
func test_jump_to_global_forces_world_center_to_zero() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(1, Vector2(400.0, 300.0)) # Region
|
||||
|
||||
v.jump_to(Vector2(777.0, 888.0), StepCanvasTransport.RUNG_GLOBAL)
|
||||
|
||||
assert_that(v.get_world_center()).is_equal(Vector2.ZERO)
|
||||
|
||||
|
||||
func test_jump_to_unrecognized_rung_is_a_no_op() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
var rung_before: String = v.get_held_rung()
|
||||
var center_before: Vector2 = v.get_world_center()
|
||||
|
||||
v.jump_to(Vector2(1.0, 2.0), "NotARealRung")
|
||||
|
||||
assert_str(v.get_held_rung()).is_equal(rung_before)
|
||||
assert_that(v.get_world_center()).is_equal(center_before)
|
||||
|
||||
|
||||
func test_get_current_canvas_summary_before_any_canvas_arrives() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
|
||||
var summary: Dictionary = v.get_current_canvas_summary()
|
||||
|
||||
assert_bool(summary.get("has_canvas", true)).is_false()
|
||||
assert_str(summary.get("rung", "")).is_equal(StepCanvasTransport.RUNG_GLOBAL)
|
||||
|
||||
|
||||
## The T-1157-inventory-relevant correctness check: course/cliff/settlement
|
||||
## counts must match a fixture canvas exactly, including the per-class
|
||||
## course histogram and settlement id dedup (mirrors
|
||||
## StepCanvasAnnotationLayer._draw_settlements()'s own dedup discipline —
|
||||
## covering the same cell id twice must not double-count).
|
||||
func test_get_current_canvas_summary_counts_match_a_fixture_canvas() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(1, Vector2(400.0, 300.0)) # Region
|
||||
|
||||
var canvas: Dictionary = _synthetic_canvas(4, 4)
|
||||
canvas["courses"] = [
|
||||
{"class": 0, "points": [[0.0, 0.0], [1.0, 1.0]]},
|
||||
{"class": 0, "points": [[2.0, 2.0], [3.0, 3.0]]},
|
||||
{"class": 2, "points": [[4.0, 4.0], [5.0, 5.0]]},
|
||||
]
|
||||
canvas["cliffs"] = [{"a": 1}, {"b": 2}]
|
||||
# 4x4 grid; ids 5 and 5 (repeat, same settlement footprint) dedup to one,
|
||||
# id 9 is a second distinct settlement, 0 is "no settlement" and ignored.
|
||||
canvas["settlement_id"] = [
|
||||
0, 5, 5, 0,
|
||||
0, 0, 0, 0,
|
||||
9, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
]
|
||||
v._on_canvas_ready(canvas)
|
||||
|
||||
var summary: Dictionary = v.get_current_canvas_summary()
|
||||
|
||||
assert_bool(summary.get("has_canvas", false)).is_true()
|
||||
assert_int(summary.get("canvas_width", 0)).is_equal(4)
|
||||
assert_int(summary.get("canvas_height", 0)).is_equal(4)
|
||||
assert_int(summary.get("course_count", 0)).is_equal(3)
|
||||
assert_int(summary.get("cliff_count", 0)).is_equal(2)
|
||||
assert_int(summary.get("settlement_count", 0)).is_equal(2)
|
||||
var by_class: Dictionary = summary.get("course_count_by_class", {})
|
||||
assert_int(int(by_class.get(0, 0))).is_equal(2)
|
||||
assert_int(int(by_class.get(2, 0))).is_equal(1)
|
||||
|
||||
Reference in New Issue
Block a user