Files
Cozypaw-Hospital/docs/superpowers/plans/2026-04-17-gut-tdd-setup.md
2026-04-17 22:22:48 +02:00

502 lines
14 KiB
Markdown

# 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_<class>.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_<class_name>.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"
```