Files
settled-reach/client/tests/test_ai_dialogue_sprint26.gd
T
jpmschweitzerandClaude Opus 4.6 4eb54a6f7a fix(client): resolve all gdlint warnings — zero warnings policy
Fix 354 gdlint warnings across 65 files: 194 class-definitions-order
(reorder declarations), 138 max-line-length (split long lines),
22 code issues (unused args, no-else-return, naming). Update .gdlintrc
to exclude addons/ and raise max-public-methods for test files.
No logic changes — declaration order, whitespace, and naming only.

Ticket: #783

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:56 +02:00

900 lines
34 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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<PlayerInput> — 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.62.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: 36 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.62.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' (36 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()