360 lines
18 KiB
GDScript
360 lines
18 KiB
GDScript
extends SceneTree
|
|
|
|
const FixtureCleanup := preload("res://tests/helpers/ui_audio_fixture_cleanup.gd")
|
|
const OperationFeedbackScript := preload("res://scripts/ui/operation_feedback.gd")
|
|
const OperationsOSObserverScript := preload("res://tests/ui/fixtures/operations_os_observer.gd")
|
|
const ManualSaveSlotsDialogScript := preload("res://scripts/ui/manual_save_slots_dialog.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _expect(ok: bool, message: String) -> void:
|
|
_checks += 1
|
|
if not ok:
|
|
_failures += 1
|
|
push_error("FAIL: " + message)
|
|
|
|
|
|
func _run() -> void:
|
|
var observer := OperationsOSObserverScript.new()
|
|
var runtime: Variant = root.get_node("SimulationRuntime")
|
|
var clock: Variant = root.get_node("GameClock")
|
|
var state: Variant = root.get_node("GameState")
|
|
|
|
clock.reset(1, 6, 0)
|
|
state.reset()
|
|
runtime.initialize(clock, state, 42534)
|
|
clock.set_paused(true)
|
|
root.size = Vector2i(1440, 810)
|
|
|
|
var main: Control = load("res://scenes/main/main.tscn").instantiate()
|
|
root.add_child(main)
|
|
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
|
|
|
await process_frame
|
|
await process_frame
|
|
|
|
var preview: WorldPreview = main.get("world_preview")
|
|
_expect(preview != null, "WorldPreview exists in Main")
|
|
var env_report: Dictionary = observer.capture_environment(self, main)
|
|
_expect(env_report[&"pid"] > 0, "Observer captured actual process PID")
|
|
|
|
# =========================================================================
|
|
# Condition 1: placement_and_invalid_drop (Grid snap, warnings, invalid rejection)
|
|
# =========================================================================
|
|
var def_registry: Variant = runtime.get("definition_registry")
|
|
var reception_def: Variant = def_registry.get_facility(&"reception") if def_registry != null else null
|
|
_expect(reception_def != null, "Reception facility definition exists")
|
|
|
|
# Valid placement commits once
|
|
var valid_cell := Vector3i(-8, 0, 0)
|
|
var build_result: Variant = runtime.submit_command(&"build_facility", {
|
|
&"definition_id": &"reception",
|
|
&"origin": valid_cell,
|
|
&"rotation": 0
|
|
})
|
|
_expect(build_result.is_success(), "Valid facility construction commits once")
|
|
|
|
# Advance time for facility to finish construction
|
|
clock.advance_minutes(120)
|
|
var facilities: Array = runtime.construction_service.get_facilities()
|
|
_expect(not facilities.is_empty(), "Facility construction completed in world")
|
|
var facility_id := StringName(facilities[0][&"instance_id"])
|
|
|
|
# Connection warning on preview
|
|
preview.set_build_definition(reception_def, 0)
|
|
var connection_preview: Dictionary = preview.get_build_preview_report(Vector3i(10, 10, 0))
|
|
_expect(connection_preview.has(&"connection"), "Build preview includes connection warning report")
|
|
|
|
# Invalid placement rejection explanation
|
|
var invalid_preview: Dictionary = preview.get_build_preview_report(Vector3i(999, 999, 0))
|
|
var rejection_text := OperationFeedbackScript.get_placement_rejection_text(invalid_preview)
|
|
_expect(not rejection_text.is_empty(), "Invalid placement explains actual rejection reason: " + rejection_text)
|
|
preview.clear_build_definition()
|
|
|
|
# Staff assignment via drop
|
|
var staff_panel: StaffManagementPanel = main.get("staff_management_panel")
|
|
_expect(staff_panel != null, "StaffManagementPanel exists")
|
|
_expect(runtime.staff_service.create_staff(&"op_worker_1", &"cleaner", {}).is_success(), "Create test employee")
|
|
staff_panel.refresh()
|
|
|
|
var drop_payload := {
|
|
&"kind": &"staff_assignment",
|
|
&"staff_id": &"op_worker_1",
|
|
&"template_id": &"DAY",
|
|
&"queue_instance_id": int(staff_panel.get("_queue_instance_id"))
|
|
}
|
|
staff_panel.assign_from_drop(drop_payload, facility_id)
|
|
_expect(staff_panel.selected_staff_id == &"op_worker_1", "Drop assignment preserves employee selection")
|
|
|
|
# Invalid assignment rejection explains actual reason and leaves model intact
|
|
var before_model: Dictionary = runtime.create_snapshot()
|
|
var invalid_drop := {
|
|
&"kind": &"staff_assignment",
|
|
&"staff_id": &"non_existent_staff",
|
|
&"template_id": &"DAY",
|
|
&"queue_instance_id": int(staff_panel.get("_queue_instance_id"))
|
|
}
|
|
staff_panel.assign_from_drop(invalid_drop, facility_id)
|
|
var after_model: Dictionary = runtime.create_snapshot()
|
|
_expect(SimulationDigest.compute(before_model) == SimulationDigest.compute(after_model), "Invalid drop leaves simulation model completely intact")
|
|
|
|
# Click-based assignment alternative
|
|
_expect(main.has_method(&"assign_staff_by_click"), "Main provides click-based assignment alternative")
|
|
staff_panel.select_staff(&"op_worker_1")
|
|
main.call(&"assign_staff_by_click", facility_id, &"op_worker_1")
|
|
_expect(staff_panel.selected_staff_id == &"op_worker_1", "Click-based assignment succeeds and leaves selection intact")
|
|
|
|
# =========================================================================
|
|
# Condition 2: modal_and_pause_resume (Modal input block, pause freezes simulation)
|
|
# =========================================================================
|
|
var save_dialog: ConfirmationDialog = main.call(&"_ensure_manual_save_slots")
|
|
_expect(save_dialog != null, "Save slots dialog is available")
|
|
var initial_manual_path: String = main.manual_save_path
|
|
save_dialog.show_new_save("user://saves/manual_ui")
|
|
await process_frame
|
|
_expect(preview.modal_input_blocked, "Behind-dialog input is blocked while modal is open")
|
|
# Behind-dialog drop assignment is rejected while modal is open
|
|
var drop_test_payload := {
|
|
&"kind": &"staff_assignment",
|
|
&"staff_id": &"op_worker_1",
|
|
&"template_id": &"DAY",
|
|
}
|
|
_expect(not preview._can_drop_data(Vector2.ZERO, drop_test_payload), "Modal suppresses behind-dialog staff assignment drop")
|
|
# Cancel modal preserves save identity and unblocks behind-dialog input
|
|
save_dialog.hide()
|
|
save_dialog.emit_signal(&"canceled")
|
|
await process_frame
|
|
_expect(not preview.modal_input_blocked, "Modal close unblocks behind-dialog input")
|
|
_expect(main.manual_save_path == initial_manual_path, "Modal cancellation preserves existing save identity")
|
|
|
|
# Defense dialog uses the ordinary visibility callback and real model.
|
|
main.call(&"_show_defense_manager")
|
|
await process_frame
|
|
_expect(preview.modal_input_blocked, "Defense dialog blocks map input on open")
|
|
preview.set_build_definition(reception_def, 0)
|
|
var defense_before: Dictionary = runtime.create_snapshot()
|
|
var defense_selection := [preview.selected_entity_id, staff_panel.selected_staff_id,
|
|
main.get("_selected_entity_id"), main.get("_selected_entity_kind")]
|
|
var behind_click := InputEventMouseButton.new()
|
|
behind_click.button_index = MOUSE_BUTTON_LEFT
|
|
behind_click.pressed = true
|
|
behind_click.position = Vector2(200, 200)
|
|
preview._gui_input(behind_click)
|
|
preview._drop_data(Vector2.ZERO, drop_payload)
|
|
_expect(SimulationDigest.compute(defense_before) == SimulationDigest.compute(runtime.create_snapshot()),
|
|
"Defense modal build/drop attempts preserve complete model including economy ledger")
|
|
_expect(defense_selection == [preview.selected_entity_id, staff_panel.selected_staff_id,
|
|
main.get("_selected_entity_id"), main.get("_selected_entity_kind")],
|
|
"Defense modal build/drop attempts preserve map and staff selections")
|
|
preview.clear_build_definition()
|
|
var defense_dialog: AcceptDialog = main.get("defense_manager")
|
|
defense_dialog.hide()
|
|
await process_frame
|
|
_expect(not preview.modal_input_blocked, "Defense dialog close unblocks map input")
|
|
|
|
# Pause freezes simulation via process_real_delta/accumulator; ordinary resume continues exactly once
|
|
var clock_paused_before: bool = bool(clock.get("paused"))
|
|
clock.set_paused(true)
|
|
var min_paused: int = int(clock.get("absolute_minute"))
|
|
# Passing non-zero real delta while paused returns 0 ticks and does not advance simulation minute
|
|
var paused_ticks: int = clock.process_real_delta(SimulationKernel.REAL_SECONDS_PER_GAME_MINUTE * 2.0)
|
|
_expect(paused_ticks == 0, "Paused clock returns 0 ticks on non-zero real process delta")
|
|
_expect(int(clock.get("absolute_minute")) == min_paused, "Paused clock freezes minute advancement on real process delta")
|
|
# Ordinary resume continues exactly once
|
|
clock.set_paused(false)
|
|
var resumed_ticks: int = clock.process_real_delta(SimulationKernel.REAL_SECONDS_PER_GAME_MINUTE)
|
|
_expect(resumed_ticks == 1, "Resumed clock on real process delta advances exactly one tick")
|
|
_expect(int(clock.get("absolute_minute")) == min_paused + 1, "Resumed clock advances simulation minute exactly once")
|
|
clock.set_paused(clock_paused_before)
|
|
|
|
# =========================================================================
|
|
# Condition 3: unit_bubbles (Zzz, anger, heart from real states)
|
|
# =========================================================================
|
|
var test_staff: StaffRuntime = runtime.staff_service.get_staff(&"op_worker_1")
|
|
_expect(test_staff != null, "Worker runtime exists for bubble test")
|
|
|
|
# SLEEPING / INCAPACITATED -> Zzz
|
|
test_staff.state = StaffRuntime.State.SLEEPING
|
|
var bubble_zzz: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff)
|
|
_expect(bubble_zzz == OperationFeedbackScript.BUBBLE_ZZZ, "Sleeping staff resolves to Zzz bubble")
|
|
observer.record_bubble(&"op_worker_1", bubble_zzz, test_staff.state)
|
|
|
|
# STRIKE / FLEEING -> anger (no unapproved morale thresholds)
|
|
test_staff.state = StaffRuntime.State.STRIKE
|
|
var bubble_anger: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff)
|
|
_expect(bubble_anger == OperationFeedbackScript.BUBBLE_ANGER, "Strike staff resolves to anger bubble")
|
|
observer.record_bubble(&"op_worker_1", bubble_anger, test_staff.state)
|
|
|
|
# Authoritative RelationshipState / RouteLock -> heart (no unapproved loyalty/morale thresholds)
|
|
var rel_state := RelationshipState.new()
|
|
rel_state.configure(&"rel_op_1", &"heroine_anri", &"op_worker_1", {
|
|
&"shield_max": 100,
|
|
&"shield_current": 100,
|
|
&"route_lock": RelationshipState.RouteLock.EXCLUSIVE_PENDING,
|
|
&"route_id": &"pure_love",
|
|
&"metrics": {&"affection": 80}
|
|
})
|
|
test_staff.state = StaffRuntime.State.AVAILABLE
|
|
var bubble_heart: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff, rel_state)
|
|
_expect(bubble_heart == OperationFeedbackScript.BUBBLE_HEART, "Authoritative relationship model resolves to heart bubble")
|
|
observer.record_bubble(&"op_worker_1", bubble_heart, test_staff.state)
|
|
|
|
# Switching state back to normal working -> clears bubble without stale symbol
|
|
test_staff.state = StaffRuntime.State.WORKING
|
|
var bubble_work: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff)
|
|
_expect(bubble_work == OperationFeedbackScript.BUBBLE_NONE, "Normal working staff clears bubble without stale symbol")
|
|
var neutral_rel := RelationshipState.new()
|
|
neutral_rel.configure(&"rel_op_neutral", &"heroine_anri", &"op_worker_1", {
|
|
&"shield_max": 100,
|
|
&"shield_current": 0,
|
|
&"route_lock": RelationshipState.RouteLock.NONE,
|
|
&"route_id": &"",
|
|
&"metrics": {&"affection": 0}
|
|
})
|
|
var bubble_cleared: StringName = OperationFeedbackScript.resolve_staff_bubble(test_staff, neutral_rel)
|
|
_expect(bubble_cleared == OperationFeedbackScript.BUBBLE_NONE, "Neutral relationship model clears heart bubble")
|
|
observer.record_bubble(&"op_worker_1", bubble_cleared, test_staff.state)
|
|
_expect(observer.get_latest_bubble(&"op_worker_1").get(&"kind", &"") == OperationFeedbackScript.BUBBLE_NONE, "Observer confirms stale bubble symbol is removed on transition to normal")
|
|
|
|
# Guest bubble: Incapacitated -> Zzz
|
|
var test_guest := GuestRuntime.new()
|
|
test_guest.guest_id = &"bubble_guest"
|
|
test_guest.state = GuestRuntime.State.INCAPACITATED
|
|
_expect(OperationFeedbackScript.resolve_guest_bubble(test_guest) == OperationFeedbackScript.BUBBLE_ZZZ, "Incapacitated guest resolves to Zzz bubble")
|
|
|
|
# Guest bubble: Abandoning -> anger
|
|
test_guest.state = GuestRuntime.State.ABANDONING
|
|
_expect(OperationFeedbackScript.resolve_guest_bubble(test_guest) == OperationFeedbackScript.BUBBLE_ANGER, "Abandoning guest resolves to anger bubble")
|
|
|
|
# Guest bubble: Normal moving -> no bubble
|
|
test_guest.state = GuestRuntime.State.MOVING
|
|
_expect(OperationFeedbackScript.resolve_guest_bubble(test_guest) == OperationFeedbackScript.BUBBLE_NONE, "Normal moving guest clears bubble without stale symbol")
|
|
|
|
# =========================================================================
|
|
# Condition 4: event_log (Bounded 256, formatted Japanese, deduplicated)
|
|
# =========================================================================
|
|
var event_dialog: EventLogDialog = main.get("event_log_dialog")
|
|
_expect(event_dialog != null, "EventLogDialog exists in Main")
|
|
var event_rows: Array[Dictionary] = event_dialog.get_event_rows()
|
|
_expect(event_rows.size() <= 256, "Event rows bounded to recent 256 entries")
|
|
|
|
var sample_event := {
|
|
"event_id": "evt_test_001",
|
|
"event_type": "DEFENSE_RAID_CREATED",
|
|
"absolute_minute": 360,
|
|
"entity_ids": [],
|
|
"amount": -500,
|
|
"reason": "RAID_START"
|
|
}
|
|
var presentation: Dictionary = event_dialog.get_event_presentation(sample_event)
|
|
_expect(presentation[&"title"] == "襲撃が発生", "Event type resolves to readable Japanese title")
|
|
_expect("G-500" in presentation[&"extra"] or "500" in presentation[&"extra"], "Event presentation includes amount details")
|
|
|
|
# =========================================================================
|
|
# Condition 5: important_warning_cutin (Invasion / Duel / Defense start)
|
|
# =========================================================================
|
|
_expect(main.has_method(&"show_warning_cutin"), "Main provides show_warning_cutin")
|
|
main.call(&"show_warning_cutin", OperationFeedbackScript.CUTIN_DEFENSE, "防衛戦開始演出", "敵の襲撃が発生しました!")
|
|
var cutin: OperationFeedback.ImportantWarningCutinDialog = main.get("important_warning_cutin")
|
|
_expect(cutin != null and cutin.visible, "Important warning cut-in is displayed")
|
|
observer.record_cutin(OperationFeedbackScript.CUTIN_DEFENSE, "防衛戦開始演出", "敵の襲撃が発生しました!")
|
|
cutin.hide()
|
|
cutin.emit_signal(&"confirmed")
|
|
await process_frame
|
|
_expect(not cutin.visible, "Important warning cut-in dismisses cleanly")
|
|
|
|
# Real domain event triggers: DEFENSE_RAID_CREATED and RELATIONSHIP_SHIELD_CHANGED break
|
|
main.call(&"_on_domain_event_published", {
|
|
&"event_type": &"DEFENSE_RAID_CREATED",
|
|
&"payload": {&"raid_id": &"raid_test_01"}
|
|
})
|
|
_expect(cutin != null and cutin.visible, "DEFENSE_RAID_CREATED domain event triggers defense cutin")
|
|
cutin.hide()
|
|
cutin.emit_signal(&"confirmed")
|
|
await process_frame
|
|
|
|
main.call(&"_on_domain_event_published", {
|
|
&"event_type": &"RELATIONSHIP_SHIELD_CHANGED",
|
|
&"payload": {&"old_value": 50, &"new_value": 0}
|
|
})
|
|
_expect(cutin != null and cutin.visible, "Shield vanishing to 0 triggers love shield warning cutin")
|
|
cutin.hide()
|
|
cutin.emit_signal(&"confirmed")
|
|
await process_frame
|
|
|
|
# Generic INCIDENT_CREATED does not falsely trigger warning cutin
|
|
main.call(&"_on_domain_event_published", {
|
|
&"event_type": &"INCIDENT_CREATED",
|
|
&"payload": {&"incident_type": &"illegal_facility"}
|
|
})
|
|
_expect(not cutin.visible, "Generic INCIDENT_CREATED does not trigger warning cutin")
|
|
|
|
# =========================================================================
|
|
# Condition 6: click_alternatives (OperationsCameraControls click equivalents)
|
|
# =========================================================================
|
|
var camera_ctrl: OperationsCameraControls = main.get("operations_camera_controls")
|
|
_expect(camera_ctrl != null, "OperationsCameraControls exists in Main")
|
|
|
|
var initial_zoom: float = preview.zoom_level
|
|
camera_ctrl.zoom_in_button.emit_signal(&"pressed")
|
|
_expect(preview.zoom_level > initial_zoom, "Click zoom in increments zoom level")
|
|
|
|
camera_ctrl.zoom_out_button.emit_signal(&"pressed")
|
|
_expect(is_equal_approx(preview.zoom_level, initial_zoom), "Click zoom out restores zoom level")
|
|
|
|
var initial_pan: Vector2 = preview.pan_offset
|
|
camera_ctrl.pan_up_button.emit_signal(&"pressed")
|
|
_expect(preview.pan_offset != initial_pan, "Click pan moves pan offset")
|
|
|
|
camera_ctrl.reset_button.emit_signal(&"pressed")
|
|
_expect(preview.zoom_level >= 0.5 and preview.zoom_level <= 4.0, "Click reset maintains valid viewport framing")
|
|
|
|
var grid_before: bool = preview.show_grid
|
|
camera_ctrl.grid_button.emit_signal(&"pressed")
|
|
_expect(preview.show_grid != grid_before, "Click grid button toggles grid display")
|
|
camera_ctrl.grid_button.emit_signal(&"pressed")
|
|
|
|
# =========================================================================
|
|
# Condition 7: japanese_input_and_scale (Japanese save label, cryptographic path)
|
|
# =========================================================================
|
|
var label_edit: LineEdit = save_dialog.find_child("ManualSaveLabelEdit", true, false) as LineEdit
|
|
_expect(label_edit != null, "Manual save slots dialog contains LineEdit for Japanese label input")
|
|
|
|
save_dialog.show_new_save("user://saves/manual_ui")
|
|
_expect(not label_edit.text.is_empty(), "Save label has valid Japanese default text: " + label_edit.text)
|
|
|
|
# User inputs Japanese text
|
|
var custom_label := "世界樹第壱号拠点・本番セーブ"
|
|
label_edit.text = custom_label
|
|
label_edit.emit_signal(&"text_changed", custom_label)
|
|
_expect(save_dialog.get_ok_button().disabled == false, "Valid Japanese text allows save confirmation")
|
|
|
|
# Clear / cancel semantics
|
|
save_dialog.hide()
|
|
save_dialog.emit_signal(&"canceled")
|
|
_expect(label_edit.text.is_empty(), "Dialog cancel clears input label")
|
|
|
|
# Cryptographic owned path check
|
|
_expect(ManualSaveSlotsDialogScript.is_owned_slot_name("manual_ui_0123456789abcdef0123456789abcdef"), "Cryptographic slot path regex matches owned specification")
|
|
_expect(not ManualSaveSlotsDialogScript.is_owned_slot_name("user_custom_folder_name"), "User custom folder name rejected from cryptographic slot rule")
|
|
|
|
# =========================================================================
|
|
# Condition 8: final_validation_review (All 8 conditions checked, ordinary cleanup)
|
|
# =========================================================================
|
|
var observer_summary: Dictionary = observer.get_summary()
|
|
_expect(observer_summary[&"pid"] > 0, "Observer tracks non-zero PID")
|
|
_expect(observer_summary[&"bubbles_tracked"] >= 3, "Observer tracked at least 3 unit bubble events")
|
|
_expect(observer_summary[&"unique_actors_tracked"] >= 1, "Observer maintains latest state map for tracked actors")
|
|
|
|
var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main)
|
|
_expect(bool(cleanup.get(&"passed", false)), "Ordinary Main fixture and audio cleanup succeeds")
|
|
|
|
print("Operations notification: %d checks, %d failures" % [_checks, _failures])
|
|
quit(1 if _failures else 0)
|