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

14 KiB

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:

$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:

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
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:

## 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:

{
  "dirs": ["res://test/"],
  "prefix": "test_",
  "suffix": ".gd",
  "include_subdirs": true,
  "log_level": 1,
  "export_path": ""
}
  • Step 3: Run the smoke test headlessly
"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:

"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
  • Step 4: Commit
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:

## 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
"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
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:

## 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
"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
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:

## 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
"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
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:

### 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
"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
git add CLAUDE.md
git commit -m "docs(claude): update testing conventions to reflect GUT TDD workflow"