# GUT TDD Setup Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Install GUT v9.6.0, configure headless test runner, and write tests for the core logic layers (GameState, RoomNavigator, GiftBox FSM) as the TDD foundation for all future sprints. **Architecture:** GUT addon installed to `addons/gut/`. Tests live in `test/unit/`. Each testable class gets its own `test_.gd` file. Autoloads and components are instantiated fresh per test using `add_child_autofree()`. Headless runner: `godot --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit`. Future sprints write failing tests first, then implement. **Tech Stack:** Godot 4.6.2, GUT v9.6.0, GDScript static typing, PowerShell (Windows) for download --- ### Task 1: Download and install GUT v9.6.0 **Files:** - Create: `addons/gut/` (extracted from GitHub zip) - Modify: `project.godot` - [ ] **Step 1: Download and extract GUT** Run in PowerShell from the project root `F:\Development\_gameDev\Cozypaw-Hospital`: ```powershell $zipUrl = "https://github.com/bitwes/Gut/archive/refs/tags/v9.6.0.zip" $zipPath = "$env:TEMP\gut_v9.6.0.zip" $extractPath = "$env:TEMP\gut_extract_v9" Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force Copy-Item -Path "$extractPath\Gut-9.6.0\addons\gut" -Destination "addons\gut" -Recurse Remove-Item -Path $zipPath, $extractPath -Recurse -Force ``` Expected: `addons/gut/` now contains `plugin.cfg`, `gut.gd`, `gut_cmdln.gd`, and many other files. Verify: ```powershell ls addons/gut/ | Select-Object -First 10 ``` - [ ] **Step 2: Enable GUT plugin in project.godot** In `project.godot`, add the `[editor_plugins]` section at the end of the file: ``` [editor_plugins] enabled=PackedStringArray("res://addons/gut/plugin.cfg") ``` - [ ] **Step 3: Commit** ```bash cd "F:/Development/_gameDev/Cozypaw-Hospital" git add addons/gut/ project.godot git commit -m "chore(tooling): install GUT v9.6.0 test framework" ``` --- ### Task 2: Test infrastructure and smoke test **Files:** - Create: `test/unit/test_smoke.gd` - Create: `.gutconfig.json` - [ ] **Step 1: Create test directory and smoke test** Create `test/unit/test_smoke.gd`: ```gdscript ## Smoke test — verifies GUT is installed and the test runner works. extends GutTest func test_gut_is_working() -> void: assert_true(true) func test_basic_arithmetic() -> void: assert_eq(1 + 1, 2) ``` - [ ] **Step 2: Create GUT config file** Create `.gutconfig.json` in the project root `F:\Development\_gameDev\Cozypaw-Hospital`: ```json { "dirs": ["res://test/"], "prefix": "test_", "suffix": ".gd", "include_subdirs": true, "log_level": 1, "export_path": "" } ``` - [ ] **Step 3: Run the smoke test headlessly** ```bash "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: output ends with something like `All tests passed` or `0 failures`. Exit code 0. If Godot needs to import assets first (first run with new files), run once with `--import` and ignore output, then re-run the test command above: ```bash "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1 ``` - [ ] **Step 4: Commit** ```bash git add test/unit/test_smoke.gd .gutconfig.json git commit -m "chore(tooling): add GUT test infrastructure and smoke test" ``` --- ### Task 3: GameState tests **Files:** - Create: `test/unit/test_game_state.gd` The implementation (`scripts/autoload/GameState.gd`) already exists. These tests verify it behaves correctly and serve as a regression baseline. - [ ] **Step 1: Create the test file** `test/unit/test_game_state.gd`: ```gdscript ## Tests for GameState — character positions, object states, save/load round-trip. extends GutTest const GameStateScript: GDScript = preload("res://scripts/autoload/GameState.gd") var _state: Node func before_each() -> void: _state = GameStateScript.new() add_child_autofree(_state) func test_get_character_position_default_is_zero() -> void: assert_eq(_state.get_character_position("bunny_01"), Vector2.ZERO) func test_has_character_position_returns_false_for_unknown_id() -> void: assert_false(_state.has_character_position("bunny_01")) func test_set_character_position_stores_value() -> void: _state.set_character_position("bunny_01", Vector2(100.0, 200.0)) assert_eq(_state.get_character_position("bunny_01"), Vector2(100.0, 200.0)) func test_has_character_position_returns_true_after_set() -> void: _state.set_character_position("bunny_01", Vector2(50.0, 50.0)) assert_true(_state.has_character_position("bunny_01")) func test_set_character_position_emits_character_moved() -> void: watch_signals(_state) _state.set_character_position("bunny_01", Vector2(100.0, 200.0)) assert_signal_emitted(_state, "character_moved") func test_set_character_position_emits_state_changed() -> void: watch_signals(_state) _state.set_character_position("bunny_01", Vector2(100.0, 200.0)) assert_signal_emitted(_state, "state_changed") func test_get_object_state_default_is_idle() -> void: assert_eq(_state.get_object_state("gift_box_1"), "idle") func test_set_object_state_stores_value() -> void: _state.set_object_state("gift_box_1", "open") assert_eq(_state.get_object_state("gift_box_1"), "open") func test_set_object_state_emits_state_changed() -> void: watch_signals(_state) _state.set_object_state("gift_box_1", "open") assert_signal_emitted(_state, "state_changed") func test_save_data_round_trip_character_position() -> void: _state.set_character_position("bunny_01", Vector2(100.0, 200.0)) var data: Dictionary = _state.get_save_data() _state.set_character_position("bunny_01", Vector2.ZERO) _state.apply_save_data(data) assert_eq(_state.get_character_position("bunny_01"), Vector2(100.0, 200.0)) func test_save_data_round_trip_object_state() -> void: _state.set_object_state("gift_box_1", "open") var data: Dictionary = _state.get_save_data() _state.set_object_state("gift_box_1", "idle") _state.apply_save_data(data) assert_eq(_state.get_object_state("gift_box_1"), "open") func test_apply_save_data_with_empty_dict_does_not_crash() -> void: _state.set_character_position("bunny_01", Vector2(10.0, 20.0)) _state.apply_save_data({}) assert_eq(_state.get_character_position("bunny_01"), Vector2.ZERO) ``` - [ ] **Step 2: Run tests** ```bash "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: All 12 GameState tests pass. 0 failures. - [ ] **Step 3: Commit** ```bash git add test/unit/test_game_state.gd git commit -m "test(game-state): add unit tests for character positions, object states, and save round-trip" ``` --- ### Task 4: RoomNavigator tests **Files:** - Create: `test/unit/test_room_navigator.gd` `RoomNavigator` extends `Node` (no `class_name`). Each test gets a fresh instance with its own Camera2D. State variables (`_current_floor`, `_current_room`, `_is_at_home`) are set synchronously before tweens start — assertions work immediately without awaiting tween completion. - [ ] **Step 1: Create the test file** `test/unit/test_room_navigator.gd`: ```gdscript ## Tests for RoomNavigator — floor/room state, home/hospital navigation logic. extends GutTest const RoomNavigatorScript: GDScript = preload("res://scripts/systems/room_navigator.gd") var _nav: Node var _camera: Camera2D func before_each() -> void: _nav = RoomNavigatorScript.new() add_child_autofree(_nav) _camera = Camera2D.new() add_child_autofree(_camera) _nav.initialize(_camera) func test_initial_floor_is_zero() -> void: assert_eq(_nav.get_current_floor(), 0) func test_initial_room_is_zero() -> void: assert_eq(_nav.get_current_room(), 0) func test_is_at_home_starts_false() -> void: assert_false(_nav.is_at_home()) func test_go_to_room_updates_floor() -> void: _nav.go_to_room(1, 2) assert_eq(_nav.get_current_floor(), 1) func test_go_to_room_updates_room() -> void: _nav.go_to_room(1, 2) assert_eq(_nav.get_current_room(), 2) func test_go_to_room_same_position_when_not_at_home_is_noop() -> void: _nav.go_to_room(1, 2) var camera_pos: Vector2 = _camera.position _nav.go_to_room(1, 2) assert_eq(_camera.position, camera_pos) func test_go_to_floor_navigates_to_room_zero() -> void: _nav.go_to_floor(2) assert_eq(_nav.get_current_floor(), 2) assert_eq(_nav.get_current_room(), 0) func test_go_to_home_sets_is_at_home_true() -> void: _nav.go_to_home() assert_true(_nav.is_at_home()) func test_go_to_home_twice_is_noop_on_second_call() -> void: _nav.go_to_home() var camera_pos: Vector2 = _camera.position _nav.go_to_home() assert_eq(_camera.position, camera_pos) func test_go_to_hospital_clears_is_at_home() -> void: _nav.go_to_home() _nav.go_to_hospital() assert_false(_nav.is_at_home()) func test_go_to_hospital_restores_last_floor() -> void: _nav.go_to_room(1, 2) _nav.go_to_home() _nav.go_to_hospital() assert_eq(_nav.get_current_floor(), 1) func test_go_to_hospital_restores_last_room() -> void: _nav.go_to_room(1, 2) _nav.go_to_home() _nav.go_to_hospital() assert_eq(_nav.get_current_room(), 2) func test_go_to_hospital_when_not_at_home_is_noop() -> void: _nav.go_to_room(1, 2) var camera_pos: Vector2 = _camera.position _nav.go_to_hospital() assert_eq(_camera.position, camera_pos) func test_go_to_room_after_home_clears_is_at_home() -> void: _nav.go_to_home() _nav.go_to_room(0, 0) assert_false(_nav.is_at_home()) ``` - [ ] **Step 2: Run tests** ```bash "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: All RoomNavigator + GameState + smoke tests pass. 0 failures. - [ ] **Step 3: Commit** ```bash git add test/unit/test_room_navigator.gd git commit -m "test(room-navigator): add unit tests for floor/room state and home/hospital navigation" ``` --- ### Task 5: GiftBox FSM tests **Files:** - Create: `test/unit/test_gift_box.gd` GiftBox is instantiated from its scene (so `_ready()` runs and hides the Gift node). State transitions are tested synchronously. Tween visual outcomes (alpha values mid-animation) are not tested — only state machine transitions. - [ ] **Step 1: Create the test file** `test/unit/test_gift_box.gd`: ```gdscript ## Tests for GiftBox — CLOSED/OPENING/OPEN state machine transitions. extends GutTest var _box: GiftBox func before_each() -> void: _box = preload("res://scenes/objects/GiftBox.tscn").instantiate() as GiftBox add_child_autofree(_box) func test_initial_state_is_closed() -> void: assert_eq(_box._state, GiftBox.State.CLOSED) func test_ready_hides_gift_node() -> void: var gift: Node2D = _box.get_node("Gift") as Node2D assert_eq(gift.modulate.a, 0.0) func test_start_opening_transitions_to_opening() -> void: _box._start_opening() assert_eq(_box._state, GiftBox.State.OPENING) func test_on_lid_opened_transitions_to_open() -> void: _box._start_opening() _box._on_lid_opened() assert_eq(_box._state, GiftBox.State.OPEN) func test_input_ignored_when_state_is_opening() -> void: _box._state = GiftBox.State.OPENING var event: InputEventScreenTouch = InputEventScreenTouch.new() event.pressed = true event.position = _box.global_position _box._input(event) assert_eq(_box._state, GiftBox.State.OPENING) func test_input_ignored_when_state_is_open() -> void: _box._state = GiftBox.State.OPEN var event: InputEventScreenTouch = InputEventScreenTouch.new() event.pressed = true event.position = _box.global_position _box._input(event) assert_eq(_box._state, GiftBox.State.OPEN) func test_tap_outside_hitbox_does_not_open() -> void: var event: InputEventScreenTouch = InputEventScreenTouch.new() event.pressed = true event.position = _box.global_position + Vector2(200.0, 200.0) _box._input(event) assert_eq(_box._state, GiftBox.State.CLOSED) func test_release_event_does_not_open() -> void: var event: InputEventScreenTouch = InputEventScreenTouch.new() event.pressed = false event.position = _box.global_position _box._input(event) assert_eq(_box._state, GiftBox.State.CLOSED) ``` - [ ] **Step 2: Run tests** ```bash "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: All GiftBox + earlier tests pass. 0 failures. - [ ] **Step 3: Commit** ```bash git add test/unit/test_gift_box.gd git commit -m "test(gift-box): add unit tests for CLOSED/OPENING/OPEN state machine" ``` --- ### Task 6: Update CLAUDE.md — TDD conventions **Files:** - Modify: `CLAUDE.md` - [ ] **Step 1: Replace the Tests section in CLAUDE.md** Find the block starting with `### Tests` and ending before `### Tests` or the next `##` section (whichever comes first). Replace the entire block with: ~~~markdown ### Tests **Framework:** GUT v9.6.0 (`addons/gut/`) **Headless runner:** "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit **TDD-Workflow (ab Sprint 15):** 1. Failing test schreiben für die neue Logik 2. Test ausführen → muss FAIL sein 3. Minimalimplementierung schreiben 4. Test ausführen → muss PASS sein 5. Committen **Was wird getestet:** - ✅ Autoloads: `GameState`, `RoomNavigator`, `SaveManager` - ✅ State-Machines: `GiftBox`, `TeaPot`, `Cradle`, `DeliveryBed`, etc. - ✅ Reine Logik-Berechnungen (Koordinaten, Serialisierung) - ❌ Tween-Animationen (visuell, kein Assert möglich) - ❌ Touch-Input (manuelle Tests auf Tablet) **Test-Dateien:** `test/unit/test_.gd`, extends `GutTest` **Device-Tests:** - Scene-Tests manuell auf echtem Android-Tablet pro Sprint - Kinder als UAT — wöchentliches Zeigen der Builds ~~~ - [ ] **Step 2: Run all tests to confirm baseline passes** ```bash "F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1 ``` Expected: All tests pass (smoke + GameState + RoomNavigator + GiftBox). 0 failures. - [ ] **Step 3: Commit** ```bash git add CLAUDE.md git commit -m "docs(claude): update testing conventions to reflect GUT TDD workflow" ```