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>
900 lines
34 KiB
GDScript
900 lines
34 KiB
GDScript
## 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.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()
|