Files
TheTree_mock/tests/ui/test_operations_notification.gd
T

335 lines
17 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")
# 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)