From d3ed73bcb39b95b579e0bc234e8ead53dfb7e2c2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:55:44 +0100 Subject: [PATCH] test(client): Sprint 26 AI dialogue + PlatformInfo tests 67 tests covering #646 AI-Enhanced Dialogue toggle, hardware detection (RAM/TPT/degradation), PlatformInfo power state, battery suspend/resume, and settings dialog integration. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_ai_dialogue_sprint26.gd | 894 ++++++++++++++++++++++ 1 file changed, 894 insertions(+) create mode 100644 client/tests/test_ai_dialogue_sprint26.gd diff --git a/client/tests/test_ai_dialogue_sprint26.gd b/client/tests/test_ai_dialogue_sprint26.gd new file mode 100644 index 000000000..984f20574 --- /dev/null +++ b/client/tests/test_ai_dialogue_sprint26.gd @@ -0,0 +1,894 @@ +## Sprint 26 — AI-Enhanced Dialogue toggle + hardware detection (#646) +## +## Test-first: written before Stig's implementation. All tests referencing +## HardwareDetector or unimplemented settings_dialog methods will be skipped +## via push_warning() until the implementation lands. +## +## Spec: D-138 (LLM re-voicing pipeline — hardware detection requirement) +## Workshop: docs/workshops/llm-voice-pipeline/workshop-outcomes.md §8 +## Ticket: #646 +class_name TestAiDialogueSprint26 +extends GdUnitTestSuite + + +var _original_ai_enabled: bool = true + + +func before_test() -> void: + _original_ai_enabled = GameState.get("ai_enhanced_dialogue_enabled") if \ + "ai_enhanced_dialogue_enabled" in GameState else true + + +func after_test() -> void: + if "ai_enhanced_dialogue_enabled" in GameState: + GameState.set("ai_enhanced_dialogue_enabled", _original_ai_enabled) + + +func _get_detector() -> Object: + var node := get_node_or_null("/root/HardwareDetector") + if node == null: + push_warning("TestAiDialogueSprint26: HardwareDetector autoload not found — test skipped (awaiting #646)") + return node + + +# -- GameState field ---------------------------------------------------------- + +func test_game_state_has_ai_enhanced_dialogue_field() -> void: + # D-138: toggle state must live in GameState so all subsystems can read it. + assert_bool("ai_enhanced_dialogue_enabled" in GameState).override_failure_message( + "GameState must have ai_enhanced_dialogue_enabled field (#646)" + ).is_true() + + +func test_game_state_ai_enhanced_dialogue_default_is_true() -> void: + # D-138: feature on by default — player can opt out, not opt in. + var enabled: Variant = GameState.get("ai_enhanced_dialogue_enabled") + assert_bool(enabled).override_failure_message( + "GameState.ai_enhanced_dialogue_enabled must default to true (D-138)" + ).is_true() + + +func test_game_state_ai_dialogue_toggle_can_be_set_false() -> void: + # Toggle must be writable — settings dialog needs to persist changes. + GameState.set("ai_enhanced_dialogue_enabled", false) + assert_bool(GameState.ai_enhanced_dialogue_enabled).override_failure_message( + "GameState.ai_enhanced_dialogue_enabled must be settable to false" + ).is_false() + + +func test_game_state_ai_dialogue_toggle_can_be_set_true() -> void: + # Re-enable after disable — full round-trip. + GameState.set("ai_enhanced_dialogue_enabled", false) + GameState.set("ai_enhanced_dialogue_enabled", true) + assert_bool(GameState.ai_enhanced_dialogue_enabled).override_failure_message( + "GameState.ai_enhanced_dialogue_enabled must be re-settable to true" + ).is_true() + + +# -- InputMapper.Action enum -------------------------------------------------- + +func test_input_mapper_has_change_settings_action() -> void: + # ChangeSettings dispatches through the existing PlayerInput pipeline. + assert_bool("CHANGE_SETTINGS" in InputMapper.Action).override_failure_message( + "InputMapper.Action must include CHANGE_SETTINGS variant (#646)" + ).is_true() + + +# -- Wire protocol (sim_bridge → server) -------------------------------------- + +func test_sim_bridge_maps_change_settings_to_wire_name() -> void: + # sim_bridge must not silently drop the action — empty string = dropped. + if not "CHANGE_SETTINGS" in InputMapper.Action: + push_warning("TestAiDialogueSprint26: CHANGE_SETTINGS action not found — test skipped") + return + var wire_name: String = SimBridge.action_enum_to_wire(InputMapper.Action.CHANGE_SETTINGS) + assert_str(wire_name).override_failure_message( + "CHANGE_SETTINGS must map to 'ChangeSettings' wire name" + ).is_equal("ChangeSettings") + + +func test_protocol_encode_change_settings_produces_valid_bytes() -> void: + # Protocol.encode_change_settings() is the helper for testability (mirrors encode_startup_message). + var bytes: PackedByteArray = Protocol.encode_change_settings(true) + assert_bool(bytes.size() > 0).override_failure_message( + "Protocol.encode_change_settings(true) must produce non-empty bytes" + ).is_true() + + +func test_protocol_change_settings_wire_has_action_name_key() -> void: + # Wire payload is Vec — each input map must have "action_name". + var bytes: PackedByteArray = Protocol.encode_change_settings(true) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status).is_null() + var inputs: Array = decoded.value + assert_bool(inputs.size() > 0).override_failure_message( + "ChangeSettings wire payload must be a non-empty array of PlayerInputs" + ).is_true() + assert_str(inputs[0].get("action_name", "")).override_failure_message( + "PlayerInput.action_name must be 'ChangeSettings'" + ).is_equal("ChangeSettings") + + +func test_protocol_change_settings_wire_enabled_true() -> void: + # action_data.ai_enhanced_dialogue = true when enabling. + var bytes: PackedByteArray = Protocol.encode_change_settings(true) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status).is_null() + var inputs: Array = decoded.value + var action_data: Dictionary = inputs[0].get("action_data", {}) + assert_bool(action_data.get("ai_enhanced_dialogue", false)).override_failure_message( + "ChangeSettings action_data.ai_enhanced_dialogue must be true when enabling" + ).is_true() + + +func test_protocol_change_settings_wire_enabled_false() -> void: + # action_data.ai_enhanced_dialogue = false when disabling. + var bytes: PackedByteArray = Protocol.encode_change_settings(false) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status).is_null() + var inputs: Array = decoded.value + var action_data: Dictionary = inputs[0].get("action_data", {}) + assert_bool(action_data.get("ai_enhanced_dialogue", true)).override_failure_message( + "ChangeSettings action_data.ai_enhanced_dialogue must be false when disabling" + ).is_false() + + +# -- HardwareDetector constants (D-138 §8) ------------------------------------ + +func test_hardware_detector_ram_pass_threshold_is_2000mb() -> void: + # D-138 §8 Layer 1: ≥ 2.0 GB free RAM → pass. + var det := _get_detector() + if det == null: + return + assert_float(float(det.RAM_PASS_THRESHOLD_MB)).override_failure_message( + "RAM_PASS_THRESHOLD_MB must be 2000.0 (D-138 §8)" + ).is_equal(2000.0) + + +func test_hardware_detector_ram_marginal_threshold_is_1600mb() -> void: + # D-138 §8 Layer 1: 1.6–2.0 GB → marginal (warn, let player proceed). + var det := _get_detector() + if det == null: + return + assert_float(float(det.RAM_MARGINAL_THRESHOLD_MB)).override_failure_message( + "RAM_MARGINAL_THRESHOLD_MB must be 1600.0 (D-138 §8)" + ).is_equal(1600.0) + + +func test_hardware_detector_tpt_green_threshold_is_6() -> void: + # D-138 §8 Layer 2: ≥ 6 t/s → green (enable silently). + var det := _get_detector() + if det == null: + return + assert_float(float(det.TPT_GREEN_THRESHOLD)).override_failure_message( + "TPT_GREEN_THRESHOLD must be 6.0 (D-138 §8)" + ).is_equal(6.0) + + +func test_hardware_detector_tpt_yellow_threshold_is_3() -> void: + # D-138 §8 Layer 2: 3–6 t/s → yellow (partial pre-voicing message). + var det := _get_detector() + if det == null: + return + assert_float(float(det.TPT_YELLOW_THRESHOLD)).override_failure_message( + "TPT_YELLOW_THRESHOLD must be 3.0 (D-138 §8)" + ).is_equal(3.0) + + +func test_hardware_detector_degradation_threshold_is_0_4() -> void: + # D-138 §8 Layer 3: sustained >40% TPT drop from baseline → yellow status. + var det := _get_detector() + if det == null: + return + assert_float(float(det.TPT_DEGRADATION_THRESHOLD)).override_failure_message( + "TPT_DEGRADATION_THRESHOLD must be 0.4 (D-138 §8 Layer 3)" + ).is_equal(0.4) + + +# -- Layer 1: classify_ram() -------------------------------------------------- + +func test_hardware_classify_ram_pass_above_2gb() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(2048.0)).override_failure_message( + "classify_ram(2048) must return 'pass' (D-138 §8)" + ).is_equal("pass") + + +func test_hardware_classify_ram_pass_at_exact_boundary() -> void: + # Exactly 2000 MB is a pass (≥ 2.0 GB). + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(2000.0)).override_failure_message( + "classify_ram(2000) must return 'pass' — exact boundary" + ).is_equal("pass") + + +func test_hardware_classify_ram_marginal_between_1600_and_2000() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(1800.0)).override_failure_message( + "classify_ram(1800) must return 'marginal' (1.6–2.0 GB range)" + ).is_equal("marginal") + + +func test_hardware_classify_ram_marginal_at_lower_boundary() -> void: + # Exactly 1600 MB — marginal (≥ 1.6 GB but < 2.0 GB). + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(1600.0)).override_failure_message( + "classify_ram(1600) must return 'marginal' — exact lower boundary" + ).is_equal("marginal") + + +func test_hardware_classify_ram_fail_below_1600() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(1024.0)).override_failure_message( + "classify_ram(1024) must return 'fail' (< 1.6 GB)" + ).is_equal("fail") + + +func test_hardware_classify_ram_fail_just_below_marginal_threshold() -> void: + # 1599 MB — just below marginal, must be fail not marginal. + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(1599.0)).override_failure_message( + "classify_ram(1599) must return 'fail' — 1 MB below marginal threshold" + ).is_equal("fail") + + +# -- Layer 2: classify_tpt() -------------------------------------------------- + +func test_hardware_classify_tpt_green_above_6() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(8.0)).override_failure_message( + "classify_tpt(8.0) must return 'green' (D-138 §8)" + ).is_equal("green") + + +func test_hardware_classify_tpt_green_at_exact_threshold() -> void: + # Exactly 6 t/s → green. + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(6.0)).override_failure_message( + "classify_tpt(6.0) must return 'green' — exact boundary" + ).is_equal("green") + + +func test_hardware_classify_tpt_yellow_between_3_and_6() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(4.5)).override_failure_message( + "classify_tpt(4.5) must return 'yellow' (3–6 t/s range)" + ).is_equal("yellow") + + +func test_hardware_classify_tpt_yellow_at_lower_boundary() -> void: + # Exactly 3 t/s → yellow (≥ 3 t/s but < 6 t/s). + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(3.0)).override_failure_message( + "classify_tpt(3.0) must return 'yellow' — exact lower boundary" + ).is_equal("yellow") + + +func test_hardware_classify_tpt_red_below_3() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(1.5)).override_failure_message( + "classify_tpt(1.5) must return 'red' (< 3 t/s)" + ).is_equal("red") + + +func test_hardware_classify_tpt_red_just_below_yellow_threshold() -> void: + # 2.9 t/s — just below yellow. + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(2.9)).override_failure_message( + "classify_tpt(2.9) must return 'red' — just below yellow threshold" + ).is_equal("red") + + +# -- Layer 3: classify_degradation() ----------------------------------------- + +func test_hardware_classify_degradation_ok_at_25_percent() -> void: + # 25% slower than baseline → ok (threshold is >40%). + var det := _get_detector() + if det == null: + return + var baseline: float = 8.0 + var current_avg: float = baseline * 0.75 + assert_str(det.classify_degradation(baseline, current_avg)).override_failure_message( + "25%% degradation must return 'ok' (threshold is >40%%)" + ).is_equal("ok") + + +func test_hardware_classify_degradation_yellow_at_55_percent() -> void: + # 55% slower — thermal throttling triggers yellow notification. + var det := _get_detector() + if det == null: + return + var baseline: float = 8.0 + var current_avg: float = baseline * 0.45 + assert_str(det.classify_degradation(baseline, current_avg)).override_failure_message( + "55%% degradation must return 'yellow' (D-138 §8 Layer 3)" + ).is_equal("yellow") + + +func test_hardware_classify_degradation_ok_at_exact_40_percent() -> void: + # Spec says ">40%" → at exactly 40% the result is still "ok". + var det := _get_detector() + if det == null: + return + var baseline: float = 10.0 + var current_avg: float = 6.0 # exactly 40% slower + assert_str(det.classify_degradation(baseline, current_avg)).override_failure_message( + "Exactly 40%% degradation must return 'ok' — spec says >40%% triggers yellow" + ).is_equal("ok") + + +# -- Settings dialog: toggle label and greyed-out state ----------------------- + +func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void: + # settings_dialog needs a testable API — hardcoded UI strings are easy to drift. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + assert_bool(dialog.has_method("get_ai_dialogue_label_text")).override_failure_message( + "settings_dialog must expose get_ai_dialogue_label_text() for label drift detection" + ).is_true() + + +func test_settings_dialog_ai_dialogue_label_is_correct() -> void: + # D-138: label must be exactly "AI-Enhanced Dialogue" (Jeroen's wording). + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("get_ai_dialogue_label_text"): + push_warning("TestAiDialogueSprint26: get_ai_dialogue_label_text() not implemented — skipped") + return + assert_str(dialog.get_ai_dialogue_label_text()).override_failure_message( + "AI-Enhanced Dialogue toggle label must be exactly 'AI-Enhanced Dialogue' (D-138)" + ).is_equal("AI-Enhanced Dialogue") + + +func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void: + # D-138 §8: RAM < 1.6 GB → feature disabled, toggle greyed out. + # Player receives message but cannot enable the feature. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("set_ai_dialogue_hardware_status") \ + or not dialog.has_method("is_ai_dialogue_toggle_enabled"): + push_warning("TestAiDialogueSprint26: hardware status API not implemented — skipped") + return + dialog.set_ai_dialogue_hardware_status("fail") + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must be disabled (greyed out) when hardware status is 'fail' (D-138 §8)" + ).is_false() + + +func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void: + # "pass" → toggle available to interact with. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("set_ai_dialogue_hardware_status") \ + or not dialog.has_method("is_ai_dialogue_toggle_enabled"): + push_warning("TestAiDialogueSprint26: hardware status API not implemented — skipped") + return + dialog.set_ai_dialogue_hardware_status("pass") + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must be enabled when hardware status is 'pass'" + ).is_true() + + +func test_settings_dialog_toggle_enabled_when_hardware_marginal() -> void: + # D-138 §8: "marginal" → warn but let player proceed. Never force-disable. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("set_ai_dialogue_hardware_status") \ + or not dialog.has_method("is_ai_dialogue_toggle_enabled"): + push_warning("TestAiDialogueSprint26: hardware status API not implemented — skipped") + return + dialog.set_ai_dialogue_hardware_status("marginal") + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must remain enabled when status is 'marginal' — player can always override (D-138)" + ).is_true() + + +# -- Benchmark cache path (D-138 §8 Layer 2) ---------------------------------- + +func test_hardware_detector_benchmark_cache_path_is_correct() -> void: + # D-138 §8 Layer 2: cached in {user_data}/ai-dialogue-config.json. + var det := _get_detector() + if det == null: + return + assert_str(det.BENCHMARK_CACHE_PATH).override_failure_message( + "BENCHMARK_CACHE_PATH must be 'user://ai-dialogue-config.json' (D-138 §8)" + ).is_equal("user://ai-dialogue-config.json") + + +# -- Startup pref loading (regression guard) ---------------------------------- + +func test_hardware_detector_has_load_ai_pref_method_or_game_state_loads_on_startup() -> void: + # REGRESSION: Player preference must survive session restarts. + # Either HardwareDetector exposes load_ai_pref() so _ready() can restore + # GameState.ai_enhanced_dialogue_enabled, or another autoload must do it. + # This test fails until the startup-load path is implemented. + # Spec: D-138 §8 toggle persists via #627 (SQLite) + ConfigFile (client-local). + var det := _get_detector() + if det == null: + return + assert_bool(det.has_method("load_ai_pref")).override_failure_message( + "HardwareDetector must expose load_ai_pref() — called at startup to restore " + + "GameState.ai_enhanced_dialogue_enabled from user://settings.cfg. " + + "Without this, the toggle resets to true on every session restart." + ).is_true() + + +# ============================================================================= +# PlatformInfo (D-141) — power profile, constants, classification +# ============================================================================= +# +# Power state integer mapping (Godot 4, matches OS.POWERSTATE_* values): +# 0 = UNKNOWN, 1 = ON_BATTERY, 2 = NO_BATTERY, 3 = CHARGING, 4 = CHARGED +# +# Integer literals are used throughout — OS.POWERSTATE_* don't exist in this build. +# PlatformInfo._POWER_STATE_* are private; PlatformInfo.PowerProfile enum is public. + + +func _get_platform_info() -> Node: + var node := get_node_or_null("/root/PlatformInfo") + if node == null: + push_warning("TestAiDialogueSprint26: PlatformInfo autoload not found — test skipped (awaiting #659)") + return node + + +# -- PowerProfile enum (D-141) ------------------------------------------------ + +func test_platform_info_power_profile_full_is_0() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_int(pi.PowerProfile.FULL).override_failure_message( + "PowerProfile.FULL must be 0 (D-141)" + ).is_equal(0) + + +func test_platform_info_power_profile_battery_is_1() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_int(pi.PowerProfile.BATTERY).override_failure_message( + "PowerProfile.BATTERY must be 1 (D-141)" + ).is_equal(1) + + +func test_platform_info_power_profile_power_saver_is_2() -> void: + # POWER_SAVER = 2 is reserved for future OS API — no cross-platform detection yet. + var pi := _get_platform_info() + if pi == null: + return + assert_int(pi.PowerProfile.POWER_SAVER).override_failure_message( + "PowerProfile.POWER_SAVER must be 2 (D-141 — reserved for future API)" + ).is_equal(2) + + +func test_platform_info_power_poll_interval_is_30_seconds() -> void: + # 30-second poll timer — balances responsiveness vs CPU cost (D-141). + var pi := _get_platform_info() + if pi == null: + return + assert_float(float(pi.POWER_POLL_INTERVAL)).override_failure_message( + "POWER_POLL_INTERVAL must be 30.0 seconds (D-141)" + ).is_equal(30.0) + + +# -- classify_power_state() (D-141 / D-138 §8) -------------------------------- + +func test_platform_info_classify_on_battery_returns_battery() -> void: + # 1 = OS ON_BATTERY → "battery" — this is the suspend trigger. + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(1)).override_failure_message( + "classify_power_state(1 = ON_BATTERY) must return 'battery'" + ).is_equal("battery") + + +func test_platform_info_classify_no_battery_returns_plugged() -> void: + # 2 = NO_BATTERY (desktop) → "plugged" — never suspend on a desktop. + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(2)).override_failure_message( + "classify_power_state(2 = NO_BATTERY) must return 'plugged'" + ).is_equal("plugged") + + +func test_platform_info_classify_charging_returns_plugged() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(3)).override_failure_message( + "classify_power_state(3 = CHARGING) must return 'plugged'" + ).is_equal("plugged") + + +func test_platform_info_classify_charged_returns_plugged() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(4)).override_failure_message( + "classify_power_state(4 = CHARGED) must return 'plugged'" + ).is_equal("plugged") + + +func test_platform_info_classify_unknown_returns_unknown() -> void: + # 0 = UNKNOWN → do not suspend. Erring toward inference on ambiguous state. + # Also the default Godot returns on platforms without power API support. + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(0)).override_failure_message( + "classify_power_state(0 = UNKNOWN) must return 'unknown' — do not suspend on ambiguous state" + ).is_equal("unknown") + + +# -- should_suspend_inference() (D-141 / D-138 §8) ---------------------------- + +func test_platform_info_should_suspend_on_battery() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(1)).override_failure_message( + "should_suspend_inference(1 = ON_BATTERY) must return true (D-138 §8 Layer 3)" + ).is_true() + + +func test_platform_info_should_not_suspend_on_no_battery() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(2)).override_failure_message( + "should_suspend_inference(2 = NO_BATTERY) must return false — desktop" + ).is_false() + + +func test_platform_info_should_not_suspend_when_charging() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(3)).override_failure_message( + "should_suspend_inference(3 = CHARGING) must return false" + ).is_false() + + +func test_platform_info_should_not_suspend_when_charged() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(4)).override_failure_message( + "should_suspend_inference(4 = CHARGED) must return false" + ).is_false() + + +func test_platform_info_should_not_suspend_on_unknown() -> void: + # UNKNOWN → do not suspend. Prevents false-positive suspension on platforms + # where Godot's power API returns 0 unconditionally (e.g. some headless builds). + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(0)).override_failure_message( + "should_suspend_inference(0 = UNKNOWN) must return false — do not suspend on unknown state" + ).is_false() + + +# ============================================================================= +# HardwareDetector — power state delegation + battery suspend/resume +# ============================================================================= + + +func test_hardware_detector_classify_power_state_delegates_to_platform_info() -> void: + # HardwareDetector.classify_power_state() must agree with PlatformInfo (D-141). + var det := _get_detector() + if det == null: + return + assert_str(det.classify_power_state(1)).override_failure_message( + "HardwareDetector.classify_power_state(1) must return 'battery' (delegates to PlatformInfo)" + ).is_equal("battery") + assert_str(det.classify_power_state(3)).override_failure_message( + "HardwareDetector.classify_power_state(3) must return 'plugged'" + ).is_equal("plugged") + assert_str(det.classify_power_state(0)).override_failure_message( + "HardwareDetector.classify_power_state(0) must return 'unknown'" + ).is_equal("unknown") + + +func test_hardware_detector_should_suspend_delegates_to_platform_info() -> void: + var det := _get_detector() + if det == null: + return + assert_bool(det.should_suspend_inference(1)).override_failure_message( + "HardwareDetector.should_suspend_inference(1) must return true (delegates to PlatformInfo)" + ).is_true() + assert_bool(det.should_suspend_inference(2)).override_failure_message( + "HardwareDetector.should_suspend_inference(2) must return false" + ).is_false() + + +func test_hardware_detector_check_power_state_returns_required_keys() -> void: + var det := _get_detector() + if det == null: + return + var result: Dictionary = det.check_power_state() + assert_bool(result.has("power_state")).override_failure_message( + "check_power_state() must include 'power_state' key (raw int)" + ).is_true() + assert_bool(result.has("classification")).override_failure_message( + "check_power_state() must include 'classification' key" + ).is_true() + assert_bool(result.has("should_suspend")).override_failure_message( + "check_power_state() must include 'should_suspend' key" + ).is_true() + + +func test_hardware_detector_check_power_state_fields_are_consistent() -> void: + # The three fields must be internally consistent — not independently computed. + var det := _get_detector() + if det == null: + return + var result: Dictionary = det.check_power_state() + var expected_class: String = det.classify_power_state(result["power_state"]) + var expected_suspend: bool = det.should_suspend_inference(result["power_state"]) + assert_str(result["classification"]).override_failure_message( + "check_power_state().classification must match classify_power_state(power_state)" + ).is_equal(expected_class) + assert_bool(result["should_suspend"]).override_failure_message( + "check_power_state().should_suspend must match should_suspend_inference(power_state)" + ).is_equal(expected_suspend) + + +func test_hardware_detector_inference_not_suspended_by_default() -> void: + var det := _get_detector() + if det == null: + return + # Note: this reads live state — if tests run on battery hardware the result + # may reflect a real suspension. We document this as expected. + assert_bool(det.is_inference_suspended()).override_failure_message( + "is_inference_suspended() must be false when hardware is FULL/plugged (D-138 §8)" + ).is_false() + + +func test_hardware_detector_suspend_sets_inference_suspended() -> void: + # Directly trigger the signal handler to simulate battery transition. + # Uses PlatformInfo.PowerProfile enum values — no OS.POWERSTATE_* dependency. + var det := _get_detector() + if det == null: + return + var pi := _get_platform_info() + if pi == null: + return + # Ensure a clean starting state. + det._on_power_profile_changed(pi.PowerProfile.BATTERY, pi.PowerProfile.FULL) + assert_bool(det.is_inference_suspended()).is_false() + # Simulate plug-out → battery. + det._on_power_profile_changed(pi.PowerProfile.FULL, pi.PowerProfile.BATTERY) + assert_bool(det.is_inference_suspended()).override_failure_message( + "inference_suspended must be true after FULL→BATTERY transition (D-138 §8 Layer 3)" + ).is_true() + # Cleanup — restore before next test. + det._on_power_profile_changed(pi.PowerProfile.BATTERY, pi.PowerProfile.FULL) + + +func test_hardware_detector_resume_clears_inference_suspended() -> void: + # Suspend then resume — verify suspended flag is cleared on plug-in. + var det := _get_detector() + if det == null: + return + var pi := _get_platform_info() + if pi == null: + return + det._on_power_profile_changed(pi.PowerProfile.FULL, pi.PowerProfile.BATTERY) + assert_bool(det.is_inference_suspended()).is_true() + det._on_power_profile_changed(pi.PowerProfile.BATTERY, pi.PowerProfile.FULL) + assert_bool(det.is_inference_suspended()).override_failure_message( + "is_inference_suspended() must be false after BATTERY→FULL transition (resume on plug-in)" + ).is_false() + + +func test_hardware_detector_battery_suspend_preserves_player_pref_false() -> void: + # If the player had disabled AI dialogue, battery suspend must NOT re-enable it on resume. + # _pre_battery_pref is saved as false → resume does NOT send ChangeSettings(true). + # We verify this indirectly: inference_suspended goes false and GameState is unchanged. + var det := _get_detector() + if det == null: + return + var pi := _get_platform_info() + if pi == null: + return + var saved_pref: bool = GameState.ai_enhanced_dialogue_enabled + GameState.ai_enhanced_dialogue_enabled = false + det._on_power_profile_changed(pi.PowerProfile.FULL, pi.PowerProfile.BATTERY) + det._on_power_profile_changed(pi.PowerProfile.BATTERY, pi.PowerProfile.FULL) + assert_bool(det.is_inference_suspended()).is_false() + assert_bool(GameState.ai_enhanced_dialogue_enabled).override_failure_message( + "GameState.ai_enhanced_dialogue_enabled must remain false after battery resume — " + + "player's opt-out must survive a battery suspend/resume cycle" + ).is_false() + # Restore. + GameState.ai_enhanced_dialogue_enabled = saved_pref + + +# ============================================================================= +# Settings dialog — battery-suspended state display (D-138 §8 Layer 3) +# ============================================================================= + + +func test_settings_dialog_inference_suspended_state_defaults_false() -> void: + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + assert_bool(dialog.is_ai_inference_suspended()).override_failure_message( + "is_ai_inference_suspended() must default to false" + ).is_false() + + +func test_settings_dialog_set_inference_suspended_true() -> void: + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_inference_suspended(true) + assert_bool(dialog.is_ai_inference_suspended()).override_failure_message( + "is_ai_inference_suspended() must return true after set_ai_inference_suspended(true)" + ).is_true() + + +func test_settings_dialog_resume_clears_suspended_state() -> void: + # D-138 §8: resume when plugged in — suspended state clears. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_inference_suspended(true) + dialog.set_ai_inference_suspended(false) + assert_bool(dialog.is_ai_inference_suspended()).override_failure_message( + "is_ai_inference_suspended() must return false after set_ai_inference_suspended(false)" + ).is_false() + + +func test_settings_dialog_toggle_remains_enabled_when_battery_suspended() -> void: + # D-138: player autonomy is respected at every hardware decision. + # Battery suspend auto-pauses inference but must NOT grey the toggle — + # the player can click it to override the suspension. + # Only hardware "fail" (RAM < 1.6 GB) may disable the toggle. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_dialogue_hardware_status("pass") + dialog.set_ai_inference_suspended(true) + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must remain clickable when battery-suspended — player can override (D-138)" + ).is_true() + + +func test_settings_dialog_toggle_enabled_after_resume() -> void: + # After resume (plug-in), toggle must be enabled again. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_dialogue_hardware_status("pass") + dialog.set_ai_inference_suspended(true) + dialog.set_ai_inference_suspended(false) + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must be re-enabled after battery resume (set_ai_inference_suspended(false))" + ).is_true() + + +func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended() -> void: + # Hardware "fail" disables the toggle regardless of battery state. + # RAM < 1.6 GB is the only hard disable — battery suspend is not. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_dialogue_hardware_status("fail") + dialog.set_ai_inference_suspended(true) + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must be disabled when hardware is 'fail' — battery suspend state is irrelevant" + ).is_false() + + +func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void: + # When inference is battery-suspended, a warning label must be visible + # so the player knows why inference isn't running (even though toggle is enabled). + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("get_battery_warning_visible"): + push_warning("TestAiDialogueSprint26: get_battery_warning_visible() not yet implemented — skipped") + return + dialog.set_ai_inference_suspended(true) + assert_bool(dialog.get_battery_warning_visible()).override_failure_message( + "Battery warning label must be visible when inference is suspended (toggle is on but paused)" + ).is_true() + + +func test_settings_dialog_warning_label_hidden_when_not_suspended() -> void: + # Warning label must not show when plugged in — no battery message needed. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("get_battery_warning_visible"): + push_warning("TestAiDialogueSprint26: get_battery_warning_visible() not yet implemented — skipped") + return + dialog.set_ai_inference_suspended(false) + assert_bool(dialog.get_battery_warning_visible()).override_failure_message( + "Battery warning label must be hidden when inference is not suspended" + ).is_false()