2x2 FRONT warp at 3000G/30 materials/360 min, 15G/day and 1 Mana upkeep. Every completed warp pair is linked by one path connector, so crossing is a single step; a 3-Mana fare per rider is charged once in the step's tick. Warps close while either end is unpowered, the reserve is empty or below one fare, and blocked arrivals offer no step. Saves validate the derived links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
634 lines
35 KiB
GDScript
634 lines
35 KiB
GDScript
extends SceneTree
|
||
|
||
const AudioTeardown := preload("res://tests/helpers/ui_audio_teardown.gd")
|
||
|
||
var _manual_save_path := "user://tests/main_dashboard_%d/manual_ui" % OS.get_process_id()
|
||
var _checks := 0
|
||
var _failures := 0
|
||
var _main: Control
|
||
var _preview: WorldPreview
|
||
var _clock: Variant
|
||
var _state: Variant
|
||
var _runtime: Variant
|
||
|
||
|
||
func _initialize() -> void:
|
||
call_deferred("_run")
|
||
|
||
|
||
func _run() -> void:
|
||
_clock = root.get_node("GameClock")
|
||
_state = root.get_node("GameState")
|
||
_runtime = root.get_node("SimulationRuntime")
|
||
_clock.reset(1, 6, 0)
|
||
_state.reset()
|
||
_runtime.initialize(_clock, _state, 424242)
|
||
_clock.set_paused(true)
|
||
root.size = Vector2i(1440, 810)
|
||
_expect(
|
||
ProjectSettings.get_setting("display/window/stretch/mode") == "canvas_items"
|
||
and ProjectSettings.get_setting("display/window/stretch/aspect") == "expand"
|
||
and bool(ProjectSettings.get_setting("display/window/dpi/allow_hidpi")),
|
||
"project uses DPI-aware canvas scaling for high-resolution displays"
|
||
)
|
||
_main = load("res://scenes/main/main.tscn").instantiate() as Control
|
||
_main.set("manual_save_path", _manual_save_path)
|
||
root.add_child(_main)
|
||
await process_frame
|
||
await process_frame
|
||
_preview = _main.find_child("WorldPreview", true, false) as WorldPreview
|
||
# A deferred tick must retain its retry UI through the compact projection.
|
||
var previous_deferred: int = _runtime.get("_operations_deferred_runtime_minute")
|
||
_runtime.set("_operations_deferred_runtime_minute", _clock.absolute_minute)
|
||
_main.call("_update_pause_and_speed")
|
||
_expect((_main.get("pause_button") as Button).text == "運営再試行", "compact projection preserves deferred tick retry button")
|
||
_expect((_main.get("phase_label") as Label).text == "要確認", "compact projection preserves deferred tick warning")
|
||
_runtime.set("_operations_deferred_runtime_minute", previous_deferred)
|
||
_main.call("_update_pause_and_speed")
|
||
_expect((_main.get("pause_button") as Button).text == "再開", "cleared deferred tick restores paused resume button")
|
||
var before_weather: Dictionary = _runtime.create_snapshot()
|
||
_expect(_runtime.weather.schedule(&"storm", _clock.absolute_minute + 1)[&"ok"], "schedule storm for visible weather boundary")
|
||
_clock.advance_minutes(1)
|
||
_main.call("_refresh_dashboard")
|
||
_expect((_main.get("weather_label") as Label).text == "嵐", "HUD shows actual Runtime storm")
|
||
if OS.get_environment("THE_TREE_WEATHER_CAPTURE") == "1":
|
||
await process_frame
|
||
await RenderingServer.frame_post_draw
|
||
_expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/weather-2026-09-29/storm-hud.png")) == OK, "capture rendered storm HUD")
|
||
_expect(_runtime.restore_snapshot(before_weather), "restore weather before UI drill")
|
||
_main.call("_refresh_dashboard")
|
||
_expect((_main.get("weather_label") as Label).text == "晴れ", "HUD follows restored weather state")
|
||
var mana_label := (_main.get("resource_labels") as Dictionary)[&"mana"] as Label
|
||
var original_mana_color := mana_label.get_theme_color("font_color")
|
||
var original_mana: int = _state.get_resource(&"mana")
|
||
_state.set_resource(&"mana", 0)
|
||
_expect("停止" in mana_label.text and "0" in mana_label.text, "resource signal visibly identifies zero-reserve outage")
|
||
_expect("残量と配電" in mana_label.tooltip_text and "休止は解除されません" in mana_label.tooltip_text, "mana HUD explains recovery without promising suspension reset")
|
||
_expect("供給 +20" in mana_label.tooltip_text and "消費 −" in mana_label.tooltip_text and "蓄積 0/300" in mana_label.tooltip_text, "mana HUD shows A06 supply, consumption and storage")
|
||
_expect(mana_label.text.ends_with("/300・停止"), "mana HUD shows capacity with outage")
|
||
if OS.get_environment("THE_TREE_MANA_CAPTURE") == "1":
|
||
await process_frame
|
||
await RenderingServer.frame_post_draw
|
||
var capture := ProjectSettings.globalize_path("res://artifacts/mana-2026-09-29/outage-hud.png")
|
||
_expect(root.get_texture().get_image().save_png(capture) == OK, "capture rendered outage HUD")
|
||
_state.set_resource(&"mana", original_mana)
|
||
_expect(not "停止" in mana_label.text and mana_label.get_theme_color("font_color") == original_mana_color, "resource recovery clears outage label and alert color")
|
||
|
||
_expect(_preview != null, "dashboard creates the live world preview")
|
||
var visual_theme := _main.get("visual_theme") as VisualThemeProfile
|
||
_expect(
|
||
visual_theme != null
|
||
and visual_theme.world_background_for_layer(WorldPreview.LayerMode.FRONT) != null
|
||
and visual_theme.world_background_for_layer(WorldPreview.LayerMode.BACK) != null,
|
||
"replaceable visual theme supplies independent FRONT and BACK art"
|
||
)
|
||
_expect(
|
||
_preview.visual_theme == visual_theme,
|
||
"world preview consumes the scene theme instead of hard-coded asset paths"
|
||
)
|
||
_expect(
|
||
visual_theme.facility_icons.size() == 23
|
||
and visual_theme.navigation_icons.size() == 6
|
||
and visual_theme.resource_icons.size() == 4
|
||
and visual_theme.control_icons.size() == 6,
|
||
"replaceable visual theme provides complete semantic icon assignments"
|
||
)
|
||
_expect(
|
||
visual_theme.guest_actor_marker(&"mage_elf") != null
|
||
and visual_theme.guest_actor_marker(&"legacy_guest") != null
|
||
and visual_theme.staff_actor_marker(&"guard") != null
|
||
and visual_theme.staff_actor_marker(&"legacy_role") != null,
|
||
"visual theme resolves class, role, and fallback actor markers"
|
||
)
|
||
_expect(
|
||
visual_theme.ui_skin_textures.size() == 16
|
||
and visual_theme.ui_skin(&"panel_parchment") != null
|
||
and visual_theme.ui_skin(&"button_pressed") != null
|
||
and visual_theme.ui_skin(&"queue_badge") != null
|
||
and visual_theme.ui_skin(&"construction_hatch") != null,
|
||
"visual theme provides replaceable panel, button, badge, route, and construction skins"
|
||
)
|
||
for texture_path: String in [
|
||
"res://assets/themes/japanese_fantasy/world-front-v1.png.import",
|
||
"res://assets/themes/japanese_fantasy/world-back-v1.png.import",
|
||
"res://assets/themes/japanese_fantasy/facility-icons-v2.png.import",
|
||
"res://assets/themes/japanese_fantasy/system-icons-v2.png.import",
|
||
"res://assets/themes/japanese_fantasy/actor-markers-v2.png.import",
|
||
"res://assets/themes/japanese_fantasy/ui-skin-v1.png.import",
|
||
]:
|
||
var import_config := ConfigFile.new()
|
||
_expect(
|
||
import_config.load(texture_path) == OK
|
||
and bool(import_config.get_value("params", "mipmaps/generate", false)),
|
||
"high-resolution texture enables mipmaps: %s" % texture_path.get_file()
|
||
)
|
||
var operations: Dictionary = _runtime.get_operations_report()
|
||
_expect(bool(operations.get(&"enabled", false)), "dashboard enables the production Operations loop")
|
||
var summary := _main.get("dashboard_summary_label") as Label
|
||
_expect(
|
||
summary != null and "行動点" in summary.text and "未払経費" in summary.text,
|
||
"dashboard exposes action points and payroll arrears"
|
||
)
|
||
await _test_defense_notice()
|
||
var palette := _main.find_child("BuildPalette", true, false) as GridContainer
|
||
_expect(palette.get_child_count() == 24, "all24 facilities including the cleaning room and A07 warp appear in the build palette")
|
||
_expect((_main.get("build_buttons") as Dictionary).has(&"mana_warp"), "Mana warp is buildable from the ordinary palette")
|
||
var iconized_build_buttons := 0
|
||
for build_button_value: Variant in (_main.get("build_buttons") as Dictionary).values():
|
||
if (build_button_value as Button).icon != null:
|
||
iconized_build_buttons += 1
|
||
_expect(iconized_build_buttons == 23, "all facility catalog buttons consume theme-provided icons")
|
||
var premium_button: Button = (_main.get("build_buttons") as Dictionary)[&"premium_room"]
|
||
_main.call("_set_build_palette_open", true)
|
||
premium_button.button_pressed = true
|
||
_expect(_main.get("_selected_definition_id") == &"premium_room" and _main.get("_selected_definition").footprint == Vector2i(4,1), "premium palette button selects original-size placement definition")
|
||
if OS.get_environment("THE_TREE_PREMIUM_CAPTURE") == "1":
|
||
await process_frame
|
||
await RenderingServer.frame_post_draw
|
||
_expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/premium-palette.png")) == OK, "capture premium room palette")
|
||
premium_button.button_pressed = false
|
||
var dorm_button: Button = (_main.get("build_buttons") as Dictionary)[&"staff_dormitory"]
|
||
dorm_button.button_pressed = true
|
||
_expect(_main.get("_selected_definition_id") == &"staff_dormitory" and _main.get("_selected_definition").footprint == Vector2i(2,1) and _preview.layer_mode == WorldPreview.LayerMode.BACK, "dormitory palette selects original footprint and back layer")
|
||
if OS.get_environment("THE_TREE_DORMITORY_CAPTURE") == "1":
|
||
await process_frame
|
||
await RenderingServer.frame_post_draw
|
||
_expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/dormitory-palette.png")) == OK, "capture dormitory palette")
|
||
dorm_button.button_pressed = false
|
||
_main.call("_set_layer", 0)
|
||
_main.call("_set_build_palette_open", false)
|
||
var palette_scroll := _main.find_child("BuildPaletteScroll", true, false) as ScrollContainer
|
||
_expect(
|
||
palette_scroll != null and palette_scroll.is_ancestor_of(palette),
|
||
"construction catalog lives in the persistent bottom palette"
|
||
)
|
||
var navigation := _main.find_child("NavigationRail", true, false) as Control
|
||
_expect(
|
||
navigation != null and (_main.get("navigation_buttons") as Dictionary).size() == 7,
|
||
"dashboard exposes the seven Japanese management categories"
|
||
)
|
||
var iconized_navigation_buttons := 0
|
||
for navigation_button_value: Variant in (_main.get("navigation_buttons") as Dictionary).values():
|
||
if (navigation_button_value as Button).icon != null:
|
||
iconized_navigation_buttons += 1
|
||
_expect(iconized_navigation_buttons == 7, "all management categories consume theme-provided icons")
|
||
var resource_icon_nodes := _main.get("resource_icon_nodes") as Dictionary
|
||
_expect(
|
||
resource_icon_nodes.size() == 4
|
||
and (resource_icon_nodes.get(&"gold") as TextureRect).texture != null
|
||
and (resource_icon_nodes.get(&"mana") as TextureRect).texture != null,
|
||
"top HUD consumes replaceable resource icons"
|
||
)
|
||
var alert_icon := _main.find_child("AlertIcon", true, false) as TextureRect
|
||
_expect(alert_icon != null and alert_icon.texture != null, "top HUD consumes the theme warning icon")
|
||
var selected_icon := _main.find_child("SelectedEntityIcon", true, false) as TextureRect
|
||
_expect(selected_icon != null, "facility inspector reserves a theme-driven identity icon")
|
||
var guest_count := _main.get("guest_count_label") as Button
|
||
var lodging_count := _main.get("lodging_count_label") as Label
|
||
var alert_count := _main.get("alert_count_label") as Label
|
||
_expect(
|
||
guest_count != null and "来客" in guest_count.text
|
||
and lodging_count != null and "宿泊" in lodging_count.text
|
||
and alert_count != null and "警戒" in alert_count.text,
|
||
"top HUD keeps visitor, lodging, and alert information visible"
|
||
)
|
||
_expect((_main.get("build_buttons") as Dictionary).has(&"guest_elevator"), "connector proxy definitions are exposed for vertical-layout prototyping")
|
||
_expect((_main.get("build_buttons") as Dictionary).has(&"adult_private_room"), "the full MVP catalog is reachable from the scrolling palette")
|
||
await _check_layout(Vector2i(1440, 810))
|
||
await _check_layout(Vector2i(1280, 720))
|
||
await _check_layout(Vector2i(1920, 1080))
|
||
await _check_layout(Vector2i(2560, 1440))
|
||
await _check_layout(Vector2i(3840, 2160))
|
||
_test_staff_candidate_hiring_ui()
|
||
_test_excavation_rank_gate_and_tool_exclusion()
|
||
_test_coordinate_round_trip()
|
||
_test_build_payload_and_hit_test()
|
||
_test_excavation_preview_payload_and_start()
|
||
_test_manual_save_and_load()
|
||
_test_event_choice_ui()
|
||
|
||
var teardown: Dictionary = await AudioTeardown.free_owner_and_wait(self, _main)
|
||
_expect(bool(teardown.get(&"passed", false)) and not bool(teardown.get(&"deadline_reached", true)), "owned audio references release before bounded teardown deadline with whole Runtime unchanged")
|
||
SaveService.delete_save(_manual_save_path)
|
||
if _failures == 0:
|
||
print("PASS: %d checks" % _checks)
|
||
else:
|
||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||
quit(_failures)
|
||
|
||
|
||
func _check_layout(window_size: Vector2i) -> void:
|
||
root.size = window_size
|
||
await process_frame
|
||
await process_frame
|
||
var side := _main.find_child("SidePanel", true, false) as Control
|
||
var map_panel := _main.find_child("MapPanel", true, false) as Control
|
||
var bottom := _main.find_child("BottomBar", true, false) as Control
|
||
var navigation := _main.find_child("NavigationRail", true, false) as Control
|
||
var excavation_button := _main.find_child("ExcavationButton", true, false) as Button
|
||
_expect(side != null and side.size.x >= 300.0, "%dx%d keeps the operations sidebar readable" % [window_size.x, window_size.y])
|
||
_expect(map_panel != null and map_panel.size.x >= 600.0 and map_panel.size.y >= 380.0, "%dx%d keeps a usable map area" % [window_size.x, window_size.y])
|
||
_expect(
|
||
navigation != null and navigation.position.x < map_panel.position.x and side.position.x > map_panel.position.x,
|
||
"%dx%d keeps navigation left and facility inspection right of the living map" % [window_size.x, window_size.y]
|
||
)
|
||
_expect(bottom != null and bottom.position.y + bottom.size.y <= _main.size.y + 1.0, "%dx%d keeps all controls inside the viewport" % [window_size.x, window_size.y])
|
||
_expect(
|
||
excavation_button != null
|
||
and excavation_button.position.x + excavation_button.size.x <= bottom.size.x + 1.0,
|
||
"%dx%d keeps the excavation control inside the bottom bar" % [window_size.x, window_size.y]
|
||
)
|
||
|
||
|
||
func _test_staff_candidate_hiring_ui() -> void:
|
||
var panel := _main.find_child("StaffCandidatePanel", true, false) as VBoxContainer
|
||
var list := _main.find_child("StaffCandidateList", true, false) as VBoxContainer
|
||
var toggle := _main.find_child("StaffCandidateToggleButton", true, false) as Button
|
||
var buttons := _main.get("staff_candidate_buttons") as Dictionary
|
||
_expect(panel != null and not panel.visible, "staff candidate screen starts collapsed")
|
||
_expect(list != null and list.get_child_count() == 22, "twenty-one general candidates plus approved Anri recruitment have UI cards")
|
||
_expect(toggle != null and "22" in toggle.text, "collapsed hiring control exposes the available candidate count")
|
||
_expect(buttons.size() == 22 and buttons.has(&"staff_akane") and buttons.has(&"heroine_anri"), "every candidate has a stable hire control")
|
||
var akane_detail := _main.find_child("CandidateDetail_staff_akane", true, false) as Label
|
||
_expect(
|
||
akane_detail != null
|
||
and "アカネ" in akane_detail.text
|
||
and "契約 G180" in akane_detail.text
|
||
and "日給 G120" in akane_detail.text,
|
||
"candidate card shows Japanese identity and both signing and payroll costs"
|
||
)
|
||
|
||
_main.call("_unhandled_key_input", _key_event(KEY_H))
|
||
_expect(panel.visible and bool(_main.get("_staff_candidate_panel_open")), "H opens the Staff candidate screen")
|
||
var cash_before: int = int(_runtime.economy_service.get_balance(&"PLAYER_CASH"))
|
||
_main.call("_hire_staff_candidate", &"staff_akane")
|
||
_expect(_runtime.get_hired_staff_candidate_ids().has(&"staff_akane"), "paused UI hire commits the selected canonical candidate immediately")
|
||
_expect(_runtime.staff_service.has_staff(&"staff_akane"), "UI hire creates the Staff member through Runtime")
|
||
_expect(
|
||
_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 180,
|
||
"UI hire charges the catalog signing cost exactly once"
|
||
)
|
||
var akane_button := buttons.get(&"staff_akane") as Button
|
||
_expect(akane_button != null and akane_button.disabled and akane_button.text == "雇用済み", "hired candidate is visibly final and cannot be clicked again")
|
||
var after_first_hire: int = int(_runtime.economy_service.get_balance(&"PLAYER_CASH"))
|
||
_main.call("_hire_staff_candidate", &"staff_akane")
|
||
_expect(
|
||
_runtime.economy_service.get_balance(&"PLAYER_CASH") == after_first_hire
|
||
and _runtime.get_hired_staff_candidate_ids().count(&"staff_akane") == 1,
|
||
"repeated UI hire does not double-charge or duplicate the employee"
|
||
)
|
||
|
||
_clock.set_paused(false)
|
||
_main.call("_hire_staff_candidate", &"staff_bardo")
|
||
var pending := _runtime.command_queue.create_snapshot().get("pending", []) as Array
|
||
_expect(pending.size() == 1, "running-clock Staff hire is queued before mutation")
|
||
_main.call("_hire_staff_candidate", &"staff_bardo")
|
||
_expect(
|
||
(_runtime.command_queue.create_snapshot().get("pending", []) as Array).size() == 1,
|
||
"pending Staff hire suppresses repeated clicks"
|
||
)
|
||
_clock.advance_minutes(1)
|
||
_expect(
|
||
_runtime.get_hired_staff_candidate_ids().has(&"staff_bardo")
|
||
and (_main.get("_pending_hire_commands") as Dictionary).is_empty(),
|
||
"queued Staff hire completes and clears its UI pending state"
|
||
)
|
||
_clock.set_paused(true)
|
||
var candidate_summary := _main.find_child("StaffCandidateSummary", true, false) as Label
|
||
_expect(candidate_summary != null and "雇用済み 2人" in candidate_summary.text, "candidate screen refreshes its hired count from Runtime state")
|
||
_main.set("_hire_sequence", 1)
|
||
_main.call("_synchronize_ui_command_state")
|
||
_expect(
|
||
int(_main.get("_hire_sequence")) >= 3,
|
||
"saved Command metadata advances the UI hire-attempt sequence after reconstruction"
|
||
)
|
||
_main.call("_unhandled_key_input", _key_event(KEY_ESCAPE))
|
||
_expect(not panel.visible and not bool(_main.get("_staff_candidate_panel_open")), "Escape closes the Staff candidate screen")
|
||
|
||
|
||
func _test_excavation_rank_gate_and_tool_exclusion() -> void:
|
||
_main.call("_unhandled_key_input", _key_event(KEY_E))
|
||
var status := _main.get("build_status_label") as Label
|
||
var excavation_button := _main.get("excavation_button") as Button
|
||
_expect(bool(_main.get("_excavation_mode")), "E enables the explicit excavation mode")
|
||
_expect(_preview.excavation_mode, "dashboard and map share the excavation-mode state")
|
||
_expect(excavation_button != null and excavation_button.button_pressed, "excavation toggle visibly reflects the keyboard shortcut")
|
||
_expect(status != null and "ランク2" in status.text, "Rank 1 excavation gate is explained honestly in Japanese")
|
||
|
||
_main.call("_select_build_definition", &"reception")
|
||
_expect(not bool(_main.get("_excavation_mode")) and not _preview.excavation_mode, "choosing a facility exits excavation mode")
|
||
_main.call("_unhandled_key_input", _key_event(KEY_E))
|
||
_expect(_main.get("_selected_definition") == null, "entering excavation mode clears the facility build selection")
|
||
var reception_button := (_main.get("build_buttons") as Dictionary).get(&"reception") as Button
|
||
_expect(reception_button != null and not reception_button.button_pressed, "exclusive excavation mode clears the palette toggle")
|
||
_main.call("_unhandled_key_input", _key_event(KEY_ESCAPE))
|
||
_expect(not bool(_main.get("_excavation_mode")) and not _preview.excavation_mode, "Escape exits excavation mode")
|
||
var pan_button := _main.find_child("PanModeButton", true, false) as Button
|
||
_expect(pan_button != null, "camera movement has a visible text button")
|
||
_main.call("_select_build_definition", &"reception")
|
||
pan_button.pressed.emit()
|
||
_expect(_preview.pan_mode and pan_button.button_pressed and _main.get("_selected_definition") == null,
|
||
"pan button exits construction so a camera drag cannot place a facility")
|
||
_main.call("_select_build_definition", &"reception")
|
||
_expect(not _preview.pan_mode and not pan_button.button_pressed, "choosing construction exits pan mode")
|
||
pan_button.pressed.emit()
|
||
_main.call("_unhandled_key_input", _key_event(KEY_E))
|
||
_expect(not _preview.pan_mode and _preview.excavation_mode, "excavation exits pan mode")
|
||
pan_button.pressed.emit()
|
||
_expect(_preview.pan_mode and not _preview.excavation_mode, "pan mode exits excavation")
|
||
_main.call("_unhandled_key_input", _key_event(KEY_ESCAPE))
|
||
_expect(not _preview.pan_mode and not pan_button.button_pressed, "Escape exits pan mode and resets its button")
|
||
|
||
|
||
func _test_coordinate_round_trip() -> void:
|
||
_preview.layer_mode = WorldPreview.LayerMode.FRONT
|
||
for cell: Vector3i in [Vector3i(-12, 0, 0), Vector3i(0, 3, 0), Vector3i(12, 6, 0)]:
|
||
var restored: Variant = _preview.canvas_to_cell(_preview.cell_to_canvas(cell))
|
||
_expect(restored == cell, "FRONT cell-to-canvas round trip preserves %s" % cell)
|
||
_preview.layer_mode = WorldPreview.LayerMode.BACK
|
||
var back_cell := Vector3i(4, 0, 1)
|
||
_expect(_preview.canvas_to_cell(_preview.cell_to_canvas(back_cell)) == back_cell, "BACK layer coordinate round trip preserves the layer")
|
||
_expect(_preview.canvas_to_cell(_preview.get_map_rect().position-Vector2(10, 10)) == null, "points outside the map do not produce build cells")
|
||
|
||
|
||
func _test_build_payload_and_hit_test() -> void:
|
||
_main.call("_select_build_definition", &"reception")
|
||
var origin := Vector3i(-6, 0, WorldService.LAYER_FRONT)
|
||
var preview_report: Dictionary = _preview.get_build_preview_report(origin)
|
||
_expect(bool(preview_report.get(&"can_build", false)), "starter reception shows a valid authoritative placement preview")
|
||
var jobs_before: int = _runtime.construction_service.get_jobs().size()
|
||
_main.call("_on_map_cell_clicked", origin)
|
||
var jobs: Array = _runtime.construction_service.get_jobs()
|
||
_expect(jobs.size() == jobs_before + 1, "map click submits one build_facility command")
|
||
var job: Variant = jobs.back()
|
||
_expect(job.definition_id == &"reception" and job.origin == origin and job.rotation == 0, "build command payload preserves definition, cell, and rotation")
|
||
_main.call("_on_map_cell_clicked", origin)
|
||
_expect(_runtime.construction_service.get_jobs().size() == jobs.size(), "invalid overlap preview does not submit a second command")
|
||
_clock.advance_minutes(120)
|
||
var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities()
|
||
_expect(facilities.size() == 1, "dashboard-created construction completes in simulation time")
|
||
if facilities.is_empty():
|
||
return
|
||
var instance_id := StringName(facilities[0].get(&"instance_id", &""))
|
||
var facility_point := _preview.cell_to_canvas(origin)
|
||
_expect(_preview.facility_at_canvas(facility_point) == instance_id, "facility hit test resolves the completed world occupant")
|
||
_main.call("_on_facility_selected", instance_id)
|
||
var detail := _main.get("selected_detail_label") as Label
|
||
_expect(detail != null and "待機列" in detail.text and "配置人員" in detail.text, "facility selection exposes queue, service, and staffing state")
|
||
var selected_icon := _main.get("selected_entity_icon") as TextureRect
|
||
_expect(selected_icon != null and selected_icon.texture != null, "facility selection exposes its theme icon in the inspector")
|
||
|
||
|
||
func _test_excavation_preview_payload_and_start() -> void:
|
||
_expect(_promote_rank_two(), "UI fixture reaches Rank 2 through public progression commands")
|
||
_main.call("_set_layer", WorldPreview.LayerMode.FRONT)
|
||
_main.call("_set_excavation_mode", true)
|
||
var first_cell := Vector3i(0, -1, WorldPreview.LayerMode.FRONT)
|
||
_preview.call("_set_hovered_cell", first_cell)
|
||
var report := _preview.get_excavation_preview_report(first_cell)
|
||
_expect(bool(report.get(&"ok", false)), "underground hover uses the authoritative excavation validator")
|
||
var preview_cells: Variant = report.get(&"cells", [])
|
||
_expect(
|
||
preview_cells is Array
|
||
and preview_cells.has(Vector3i(0, -1, WorldPreview.LayerMode.FRONT))
|
||
and preview_cells.has(Vector3i(0, -1, WorldPreview.LayerMode.BACK)),
|
||
"excavation preview identifies both FRONT and BACK target cells"
|
||
)
|
||
var status := _main.get("build_status_label") as Label
|
||
_expect(
|
||
status != null and "掘削可能" in status.text and "費用" in status.text and "所要" in status.text,
|
||
"valid excavation preview shows Japanese cost and duration"
|
||
)
|
||
_main.call("_set_layer", WorldPreview.LayerMode.BACK)
|
||
_expect(
|
||
(_preview.get("_last_excavation_preview_report") as Dictionary).get(&"ok", false),
|
||
"changing layer recomputes the active-layer excavation preview"
|
||
)
|
||
_main.call("_set_layer", WorldPreview.LayerMode.FRONT)
|
||
_preview.call("_set_hovered_cell", first_cell)
|
||
|
||
var jobs_before: int = _runtime.excavation_service.get_jobs().size()
|
||
_clock.set_paused(false)
|
||
_main.call("_on_map_cell_clicked", first_cell)
|
||
var pending_snapshot: Dictionary = _runtime.command_queue.create_snapshot()
|
||
var pending: Array = pending_snapshot.get("pending", [])
|
||
_expect(_runtime.excavation_service.get_jobs().size() == jobs_before, "running clock queues the excavation command before mutation")
|
||
_expect(pending.size() == 1, "map click queues exactly one start_excavation command")
|
||
if not pending.is_empty():
|
||
var command: Dictionary = pending.back()
|
||
var payload: Variant = command.get("payload", {})
|
||
_expect(
|
||
StringName(command.get("command_type", "")) == &"start_excavation"
|
||
and payload is Dictionary
|
||
and payload.get(&"cell", null) == first_cell,
|
||
"excavation command payload preserves the hovered layer cell"
|
||
)
|
||
_expect(
|
||
StringName(command.get("idempotency_key", "")) == &"ui.excavation.0.-1.00000001",
|
||
"excavation uses a stable per-attempt idempotency key"
|
||
)
|
||
_expect(status != null and "受理しました" in status.text, "queued excavation reports command acceptance")
|
||
_main.call("_on_map_cell_clicked", first_cell)
|
||
_expect(
|
||
(_runtime.command_queue.create_snapshot().get("pending", []) as Array).size() == 1,
|
||
"a repeated click cannot queue a second excavation attempt while the first is pending"
|
||
)
|
||
_clock.advance_minutes(1)
|
||
var first_jobs: Array[Dictionary] = _runtime.excavation_service.get_jobs()
|
||
_expect(first_jobs.size() == jobs_before + 1, "queued excavation starts on the next Runtime command phase")
|
||
_expect((_main.get("_pending_excavation_commands") as Dictionary).is_empty(), "queued completion clears the UI pending-command entry")
|
||
_expect(status != null and "開始しました" in status.text, "queued command completion reports the actual start")
|
||
if first_jobs.is_empty():
|
||
_clock.set_paused(true)
|
||
return
|
||
var first_job: Dictionary = first_jobs.back()
|
||
_expect(
|
||
first_job.get(&"position", Vector2i.ZERO) == Vector2i(0, -1)
|
||
and int(first_job.get(&"progress", 0)) > 0,
|
||
"started excavation is visible and progresses through simulation time"
|
||
)
|
||
_clock.set_paused(true)
|
||
_clock.advance_minutes(int(first_job.get(&"build_minutes", 0)))
|
||
|
||
var second_cell := Vector3i(0, -2, WorldPreview.LayerMode.FRONT)
|
||
_preview.call("_set_hovered_cell", second_cell)
|
||
var second_report := _preview.get_excavation_preview_report(second_cell)
|
||
_expect(bool(second_report.get(&"ok", false)), "completed excavation unlocks the next connected underground cell")
|
||
var jobs_before_immediate: int = _runtime.excavation_service.get_jobs().size()
|
||
_main.call("_on_map_cell_clicked", second_cell)
|
||
_expect(
|
||
_runtime.excavation_service.get_jobs().size() == jobs_before_immediate + 1,
|
||
"paused clock executes an accepted excavation command immediately"
|
||
)
|
||
var detail := _main.get("selected_detail_label") as Label
|
||
_expect(
|
||
detail != null and "進捗" in detail.text and "FRONT / 表層・BACK / 裏層" in detail.text,
|
||
"started excavation exposes progress and both target layers in selection detail"
|
||
)
|
||
_main.call("_unhandled_key_input", _key_event(KEY_ESCAPE))
|
||
_expect(not bool(_main.get("_excavation_mode")), "Escape still exits excavation mode after starting a job")
|
||
|
||
|
||
func _promote_rank_two() -> bool:
|
||
var reputation: Variant = _runtime.submit_command(
|
||
&"update_progression_metrics",
|
||
{&"metrics": {&"reputation": 25}},
|
||
&"player", &"", 100, -1, &"ui.excavation.rank.reputation"
|
||
)
|
||
var capital: Variant = _runtime.submit_command(
|
||
&"change_resources",
|
||
{&"deltas": {&"gold": maxi(10_000 - _runtime.economy_service.get_balance(&"PLAYER_CASH"), 0)}},
|
||
&"player", &"", 100, -1, &"ui.excavation.rank.capital"
|
||
)
|
||
var promoted: Variant = _runtime.submit_command(
|
||
&"set_world_tree_rank",
|
||
{&"rank": 2},
|
||
&"player", &"", 100, -1, &"ui.excavation.rank.promote"
|
||
)
|
||
return reputation.is_success() and capital.is_success() and promoted.is_success() \
|
||
and _state.world_tree_rank == 2
|
||
|
||
|
||
func _test_manual_save_and_load() -> void:
|
||
SaveService.delete_save(_manual_save_path)
|
||
var facilities: Array = _runtime.facility_service.get_instances()
|
||
_expect(not facilities.is_empty(), "selection save fixture has a completed facility")
|
||
if facilities.is_empty():
|
||
return
|
||
var selected_id: StringName = facilities[0].instance_id
|
||
_main.call("_on_facility_selected", selected_id)
|
||
_main.call("_set_layer", 1)
|
||
_main.call("_save_manual")
|
||
var status := _main.get("build_status_label") as Label
|
||
_expect(status != null and "保存しました" in status.text, "manual save button writes the runtime snapshot")
|
||
var expected_gold: int = _state.get_resource(&"gold")
|
||
_state.set_resource(&"gold", expected_gold + 7)
|
||
_main.set("_selected_entity_id", &"")
|
||
_main.set("_selected_entity_kind", &"")
|
||
_main.call("_set_layer", 0)
|
||
_main.call("_load_manual")
|
||
_expect(_state.get_resource(&"gold") == expected_gold, "manual load button restores the saved simulation")
|
||
_expect(status != null and "読み込みました" in status.text, "manual load reports success in Japanese")
|
||
_expect(_main.get("_selected_entity_id") == selected_id and _main.get("_selected_entity_kind") == &"facility", "manual load restores saved facility selection")
|
||
_expect(_preview.selected_entity_id == selected_id, "loaded selection restores map highlight")
|
||
_expect(_preview.layer_mode == 1 and _state.active_layer == 1, "load synchronizes saved layer and visible layer")
|
||
var loaded: Dictionary = SaveService.load_snapshot(_manual_save_path)
|
||
_expect(loaded[&"manifest"].get("interface_selection", {}).get("id", "") == String(selected_id), "selection persisted in the actual save package")
|
||
# Missing or malformed optional UI metadata cannot break an otherwise valid save.
|
||
for selection: Variant in [null, {"version": 99}, {"version": 1, "kind": [], "id": String(selected_id)}, {"version": 1, "kind": "facility", "id": "missing"}, {"version": 1, "kind": "unknown", "id": String(selected_id)}]:
|
||
var saved: Dictionary = _runtime.save_to_path(_manual_save_path, {"interface_selection": selection})
|
||
_expect(saved.get(&"ok", false), "write fixture with optional presentation metadata")
|
||
_main.call("_load_manual", true)
|
||
_expect("読み込みました" in status.text and _main.get("_selected_entity_id") == &"", "invalid presentation selection is ignored without breaking gameplay load")
|
||
var old_saved: Dictionary = SaveService.save_snapshot(_manual_save_path, _runtime.create_snapshot(), {})
|
||
_expect(old_saved.get(&"ok", false), "save without UI metadata remains supported")
|
||
_main.call("_load_manual", true)
|
||
_expect(_main.get("_selected_entity_id") == &"", "legacy save loads without inventing a selection")
|
||
# Failed loading must preserve the currently selected facility.
|
||
_main.call("_on_facility_selected", selected_id)
|
||
_main.set("manual_save_path", _manual_save_path + "_missing")
|
||
_main.call("_load_manual", true)
|
||
_expect(_main.get("_selected_entity_id") == selected_id, "failed load preserves current selection")
|
||
_main.set("manual_save_path", _manual_save_path)
|
||
_main.call("_save_manual")
|
||
_main.set("_selected_entity_id", &"")
|
||
_main.set("_selected_entity_kind", &"")
|
||
_main.call("_save_manual")
|
||
var corrupt := FileAccess.open(_manual_save_path.path_join(SaveService.WORLD_FILE), FileAccess.WRITE)
|
||
corrupt.store_string("invalid test package")
|
||
corrupt.close()
|
||
_main.call("_load_manual")
|
||
_expect("読み込みました" in status.text and _main.get("_selected_entity_id") == selected_id, "backup recovery uses the selected candidate's UI metadata")
|
||
|
||
|
||
func _test_event_choice_ui() -> void:
|
||
var event_service: Variant = _runtime.event_service
|
||
var queued: Variant = event_service.enqueue_candidate(
|
||
&"relationship.threshold",
|
||
[],
|
||
_clock.absolute_minute,
|
||
{&"deduplication_key": &"ui-event-choice-test"}
|
||
)
|
||
_expect(queued.is_success(), "UI fixture enqueues a foreground relationship Event")
|
||
var started: Variant = event_service.start_next_event(_clock.absolute_minute, {})
|
||
_expect(
|
||
started.is_success() and bool(started.details.get(&"waiting_for_choice", false)),
|
||
"UI fixture starts an Event waiting for a Choice"
|
||
)
|
||
_clock.set_paused(true)
|
||
_main.call("_refresh_dashboard")
|
||
var panel := _main.find_child("ForegroundEventPanel", true, false) as VBoxContainer
|
||
var choices := _main.find_child("ForegroundEventChoices", true, false) as VBoxContainer
|
||
_expect(panel != null and panel.visible, "foreground Event opens the decision panel")
|
||
_expect(choices != null and choices.get_child_count() == 2, "all two canonical Event choices are visible")
|
||
var pause_button := _main.get("pause_button") as Button
|
||
_expect(
|
||
pause_button != null and pause_button.disabled and "イベント" in pause_button.text,
|
||
"unresolved foreground Event cannot be bypassed with the pause control"
|
||
)
|
||
if choices == null or choices.get_child_count() != 2:
|
||
return
|
||
var first_choice := choices.get_child(0) as Button
|
||
_expect(
|
||
first_choice != null and not first_choice.text.is_empty(),
|
||
"Event choice uses a readable Japanese button"
|
||
)
|
||
first_choice.emit_signal(&"pressed")
|
||
_expect(event_service.get_foreground_instance() == null, "choice button resolves the foreground Event exactly once")
|
||
_expect(not _clock.paused, "resolved foreground Event releases the simulation pause")
|
||
_expect(not panel.visible and choices.get_child_count() == 0, "resolved Event removes the stale choice controls")
|
||
_expect(not pause_button.disabled, "resolved Event restores the normal pause control")
|
||
_clock.set_paused(true)
|
||
|
||
|
||
func _test_defense_notice() -> void:
|
||
var before: Dictionary = _runtime.create_snapshot()
|
||
var notice: Label = _main.get("alert_count_label")
|
||
var summary: Label = _main.get("dashboard_summary_label")
|
||
_expect(not "襲撃" in notice.text, "empty defense keeps compact normal HUD")
|
||
var created: CommandResult = _runtime.submit_command(&"create_defense_raid", {
|
||
&"raid_id": &"ui.hud.defense", &"definition": {
|
||
&"entry_points": [&"gate"], &"objective_ids": [&"core"],
|
||
&"waves": [{&"wave_id": &"wave.one", &"threat_strength": 15}],
|
||
}}, &"defense_controller")
|
||
_expect(created.is_success(), "HUD fixture creates preparing raid through Runtime command")
|
||
await process_frame
|
||
await process_frame
|
||
_expect("襲撃 1" in notice.text and "準備中 1" in summary.text, "Runtime publication shows preparing threat in HUD and management")
|
||
_expect("配属変更できません" in notice.tooltip_text and "戦闘中 0" in notice.tooltip_text, "HUD distinguishes preparation and explains employee lock")
|
||
if OS.get_environment("THE_TREE_DEFENSE_CAPTURE") == "1":
|
||
_main.call("_set_management_panel_open", true)
|
||
await process_frame
|
||
await RenderingServer.frame_post_draw
|
||
_expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/defense-hud-2026-09-29/preparing.png")) == OK, "capture rendered defense notice")
|
||
_main.call("_set_management_panel_open", false)
|
||
var saved: Dictionary = _runtime.create_snapshot()
|
||
var cancelled: CommandResult = _runtime.submit_command(&"cancel_defense_raid", {&"raid_id": &"ui.hud.defense"}, &"defense_controller")
|
||
_expect(cancelled.is_success(), "HUD fixture cancels incomplete preparation")
|
||
await process_frame
|
||
await process_frame
|
||
_expect(not "襲撃" in notice.text and "準備中 0 / 戦闘中 0 / 次波待ち 0" in summary.text and "ありません" in notice.tooltip_text, "terminal history clears HUD warning and stale tooltip")
|
||
_expect(_runtime.restore_snapshot(saved), "restore preparing raid snapshot")
|
||
await process_frame
|
||
await process_frame
|
||
_expect("襲撃 1" in notice.text and "準備中 1" in summary.text, "HUD follows restored preparing raid without a new raid signal")
|
||
_expect(_runtime.restore_snapshot(before), "restore pre-raid gameplay")
|
||
await process_frame
|
||
await process_frame
|
||
_expect(not "襲撃" in notice.text, "restoring empty defense clears warning")
|
||
|
||
|
||
func _expect(condition: bool, message: String) -> void:
|
||
_checks += 1
|
||
if condition:
|
||
return
|
||
_failures += 1
|
||
push_error(message)
|
||
|
||
|
||
func _key_event(keycode: Key) -> InputEventKey:
|
||
var event := InputEventKey.new()
|
||
event.keycode = keycode
|
||
event.pressed = true
|
||
return event
|