## 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 const SETTINGS_DIALOG_SCENE = preload("res://ui/settings_dialog.tscn") 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 # Skip if CI is running on battery hardware — power state 1 = ON_BATTERY. # Integer literal used: OS.POWERSTATE_* constants don't exist in this build. var pi := _get_platform_info() if pi != null and pi.current_power_state() == 1: push_warning("TestAiDialogueSprint26: system is on battery (state=1) — skipping default-suspended test") return 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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 := SETTINGS_DIALOG_SCENE 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()