Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d78d166
Wire test assembly to production code and add CI test runner
norrietaylor Mar 28, 2026
6f598f7
Add EnemyStateMachine unit tests with MockEnemyState helper
norrietaylor Mar 28, 2026
a639b7f
Add enemy state unit tests with StubEnemyBase helper
norrietaylor Mar 28, 2026
df25d8b
Remove agentic workflow files and clean up .gitattributes
norrietaylor Mar 28, 2026
898c2fd
Add unit test spec, Gherkin scenarios, and validation report
norrietaylor Mar 28, 2026
06be0b8
Gate CI builds on test job passing
norrietaylor Mar 28, 2026
5bca709
Fix CI test runner crash on Unity 2022.3 shutdown (exit 133)
norrietaylor Mar 28, 2026
221b74e
Update .gitattributes
norrietaylor Mar 28, 2026
f2c2b8e
Fix CI test runner: add -nographics flag and simplify pipeline
norrietaylor Mar 28, 2026
6d8867f
Fix test assembly: set overrideReferences false to resolve Assembly-C…
norrietaylor Mar 28, 2026
8aaaf7b
Merge branch 'main' into feature/unity-unit-tests
norrietaylor Mar 28, 2026
77fe5bf
Add production assembly definition so test assembly can reference it
norrietaylor Mar 28, 2026
63d1d14
Add TextMeshPro reference to production assembly definition
norrietaylor Mar 28, 2026
5948ab6
Fix StubEnemyBase: add required components before EnemyBase in batch …
norrietaylor Mar 28, 2026
fbb0812
Fix StubEnemyBase: initialize runtime fields via reflection for batch…
norrietaylor Mar 28, 2026
78a4c9c
Fix DefeatedState tests: suppress Object.Destroy error in EditMode
norrietaylor Mar 28, 2026
06a0c30
Add preflight checks and speed up CI pipeline
norrietaylor Mar 28, 2026
0c4ec65
Update CLAUDE.md: require preflight before push, maintain script on n…
norrietaylor Mar 28, 2026
074c653
Merge branch 'main' into feature/unity-unit-tests
norrietaylor Mar 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .github/workflows/unity-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,19 @@ on:
pull_request:

jobs:
preflight:
name: Preflight Checks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preflight job checks out with default depth and then runs scripts/preflight.sh, but the script’s “new/changed file” checks are driven by git diff against a base. To make CI preflight actually evaluate PR deltas, update this checkout to fetch enough history/refs (e.g., fetch-depth: 0 and/or fetch the PR base ref) and/or pass the base ref/commit to the script so it can diff against the PR base instead of relying on a clean working tree.

Suggested change
uses: actions/checkout@v4
uses: actions/checkout@v4
with:
fetch-depth: 0

Copilot uses AI. Check for mistakes.

- name: Run preflight checks
run: ./scripts/preflight.sh
test:
needs: preflight
name: Run EditMode Tests
# Skip this job for pull requests from forks because secrets are not
# available to fork workflows and the build would fail.
Expand Down Expand Up @@ -35,6 +47,22 @@ jobs:
Library-test-
Library-

- name: Cache Docker image
uses: actions/cache@v4
with:
path: /home/runner/.docker-cache
key: docker-unity-2022.3.20f1-il2cpp-3
restore-keys: docker-unity-

- name: Load cached Docker image
run: |
if [ -f /home/runner/.docker-cache/unity-image.tar ]; then
docker load -i /home/runner/.docker-cache/unity-image.tar
echo "Docker image loaded from cache"
else
echo "No cached image found — will pull fresh"
fi

- name: Run EditMode tests
id: tests
uses: game-ci/unity-test-runner@v4
Expand All @@ -49,6 +77,16 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }}
customParameters: -nographics

- name: Save Docker image to cache
if: always()
run: |
IMAGE=$(docker images --format '{{.Repository}}:{{.Tag}}' | grep unityci | head -1)
if [ -n "$IMAGE" ] && [ ! -f /home/runner/.docker-cache/unity-image.tar ]; then
mkdir -p /home/runner/.docker-cache
docker save "$IMAGE" -o /home/runner/.docker-cache/unity-image.tar
echo "Saved $IMAGE to cache"
fi

- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
Expand Down Expand Up @@ -79,7 +117,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
lfs: true

- name: Cache Unity Library
Expand Down
77 changes: 77 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,58 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Build & Test Commands

There is no CLI build system. All building and testing happens through Unity Editor or CI.

**Running tests locally:** Unity Editor → Window → General → Test Runner → EditMode tab

Tests use Unity Test Framework (NUnit) at `Unity/Assets/Tests/EditMode/`. The test assembly (`TT2.Tests.EditMode`) uses namespace `TaekwondoTech.Tests.EditMode` and references `TT2.Runtime` (production code).

**Preflight checks (run before every push):**
```
./scripts/preflight.sh
```
This catches most CI failures locally in ~1 second: missing `.meta` files, duplicate GUIDs, 500-line violations, bad namespace imports, missing asmdef package references, and unprotected `Object.Destroy()` in EditMode tests. **Always run this before pushing.** If you encounter a new class of CI failure not caught by preflight, add a check for it to the script so future runs catch it early.

**CI pipeline** runs automatically on pushes to `main` and PRs (not forks). Three jobs in order:
1. **Preflight** (~10s) — runs `scripts/preflight.sh`
2. **EditMode Tests** (~3-4min) — `game-ci/unity-test-runner@v4` with `-nographics`
3. **Builds** (~4min each) — WebGL, iOS, Android in parallel via `game-ci/unity-builder`

Comment on lines +18 to +34
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This document now contains duplicated sections (e.g., “Build & Test Commands” and the CI pipeline description appear here and then repeat again later in the file). This makes the guidance ambiguous and harder to maintain—please consolidate into a single canonical section and remove the duplicated block(s).

Suggested change
There is no CLI build system. All building and testing happens through Unity Editor or CI.
**Running tests locally:** Unity Editor → Window → General → Test Runner → EditMode tab
Tests use Unity Test Framework (NUnit) at `Unity/Assets/Tests/EditMode/`. The test assembly (`TT2.Tests.EditMode`) uses namespace `TaekwondoTech.Tests.EditMode` and references `TT2.Runtime` (production code).
**Preflight checks (run before every push):**
```
./scripts/preflight.sh
```
This catches most CI failures locally in ~1 second: missing `.meta` files, duplicate GUIDs, 500-line violations, bad namespace imports, missing asmdef package references, and unprotected `Object.Destroy()` in EditMode tests. **Always run this before pushing.** If you encounter a new class of CI failure not caught by preflight, add a check for it to the script so future runs catch it early.
**CI pipeline** runs automatically on pushes to `main` and PRs (not forks). Three jobs in order:
1. **Preflight** (~10s) — runs `scripts/preflight.sh`
2. **EditMode Tests** (~3-4min) — `game-ci/unity-test-runner@v4` with `-nographics`
3. **Builds** (~4min each) — WebGL, iOS, Android in parallel via `game-ci/unity-builder`
For the authoritative details on local build/test workflow and the CI pipeline, see the **“Build, Test & CI pipeline”** section later in this document. That section is the single canonical source of truth and should be updated if the process changes.
**Quick local workflow (summary only):**
- Run EditMode tests via Unity Editor → Window → General → Test Runner → **EditMode** tab.
- Always run the preflight script before pushing:
```bash
./scripts/preflight.sh

This catches most CI failures locally in about a second.

Refer to the later Build, Test & CI pipeline section for the full explanation of test locations, assemblies, and CI job configuration.

Copilot uses AI. Check for mistakes.
---

## Architecture

### Key Patterns

1. **Singleton managers** — `GameManager`, `InputManager`, `ScoreManager` use `DontDestroyOnLoad()` with duplicate-prevention in `Awake()`. Access globally via static `Instance` property.

2. **Interface-driven design** — Core interfaces in `Unity/Assets/Scripts/Core/Interfaces.cs`:
- `IDamageable` — Health/damage system (Player, Enemies). Properties: `Health`, `MaxHealth`, `IsAlive`. Methods: `TakeDamage(float)`, `TakeDamage(float, GameObject)`, `Heal(float)`.
- `ICollectible` — Pickup items. `OnCollect(GameObject)`, `CollectibleType`, `Rarity`.
- `IInteractable` — Interactive objects. `Interact(GameObject)`, `CanInteract`, `InteractionPrompt`.
- `IPowerUp` — Power-ups. `Activate(GameObject)`, `Deactivate(GameObject)`, `PowerUpType`, `Duration`, `IsActive`.

3. **State machine** — Enemy AI uses `EnemyStateMachine` with `IEnemyState` interface (Enter/Execute/Exit). States: Idle, Patrol, Chase, Attack, Stunned, Defeated (in `Enemies/States/`).

4. **UnityEvent communication** — Loose coupling via events like `OnHealthChanged`, `OnPlayerDeath`, `OnEnemyDefeated`, `OnPlayerDamaged`.

### Player decomposition

Player logic is split across: `PlayerController` (movement/jumping), `PlayerCombat` (attacks), `PlayerHealth` (3-hit HP, invincibility frames), `PlayerAnimator` (animation states). This is the pattern to follow when adding complex entities.

---

## Mandatory Rules

### All C# scripts go in `Unity/Assets/Scripts/<domain>/`

Never place scripts outside the `Unity/` directory.

### Unity `.meta` files for every new asset

Every new file and folder needs a `.meta` file with a unique 32-char lowercase hex GUID. Without them, Unity can't track assets — builds break.


There is no CLI build system. All building and testing happens through Unity Editor or CI.

**Running tests locally:** Unity Editor → Window → General → Test Runner → EditMode tab
Expand Down Expand Up @@ -133,6 +185,31 @@ No single C# script may exceed 500 lines. Split into multiple classes or Scripta

## Pre-Commit Checklist

1. **Run `./scripts/preflight.sh`** — must pass before pushing
2. All new `.cs` files are in `Unity/Assets/Scripts/<domain>/`
3. Every new `.cs` file has a `.cs.meta` with unique GUID
4. Every new folder has a `.meta` file in its parent directory
5. All scripts use correct `TaekwondoTech.*` namespace
6. No file exceeds 500 lines
7. 4-space indent, LF line endings
8. Any `IDamageable` implementation includes all required members
9. New `using` directives for external packages (e.g., `TMPro`) must have a matching reference in `Unity/Assets/Scripts/TT2.Runtime.asmdef`
10. EditMode tests calling production code that uses `Object.Destroy()` must wrap the call with `LogAssert.ignoreFailingMessages = true/false`

**If CI fails with a new class of error**, add a corresponding check to `scripts/preflight.sh` before fixing the code, so the same error is caught locally in future.

---

## Branch & PR Conventions

- Branch naming: `feature/<desc>`, `fix/<desc>`, `chore/<desc>`
- PR titles: imperative mood — "Add X" not "Added X"
- One approving review required before merge

---

## Pre-Commit Checklist

- [ ] All new `.cs` files are in `Unity/Assets/Scripts/<domain>/`
- [ ] Every new `.cs` file has a `.cs.meta` with unique GUID
- [ ] Every new folder has a `.meta` file in its parent directory
Expand Down
173 changes: 173 additions & 0 deletions scripts/preflight.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# Pre-push smoke check for Unity C# project.
# Catches the classes of CI failures we've hit without needing Unity installed.
# Run: ./scripts/preflight.sh

set -euo pipefail

SCRIPTS_DIR="Unity/Assets/Scripts"
TESTS_DIR="Unity/Assets/Tests"
RUNTIME_ASMDEF="$SCRIPTS_DIR/TT2.Runtime.asmdef"
ERRORS=0

red() { printf '\033[0;31m%s\033[0m\n' "$1"; }
green() { printf '\033[0;32m%s\033[0m\n' "$1"; }
warn() { printf '\033[0;33m%s\033[0m\n' "$1"; }

fail() { red "FAIL: $1"; ERRORS=$((ERRORS + 1)); }
pass() { green "OK: $1"; }

echo "=== Unity Preflight Checks ==="
echo ""

# 1. Every .cs file has a .meta file
echo "--- Meta files ---"
MISSING_META=0
while IFS= read -r cs_file; do
if [ ! -f "${cs_file}.meta" ]; then
fail "Missing .meta for $cs_file"
MISSING_META=$((MISSING_META + 1))
fi
done < <(find Unity/Assets -name "*.cs" -not -path "*/Library/*" 2>/dev/null)

while IFS= read -r asmdef_file; do
if [ ! -f "${asmdef_file}.meta" ]; then
fail "Missing .meta for $asmdef_file"
MISSING_META=$((MISSING_META + 1))
fi
done < <(find Unity/Assets -name "*.asmdef" -not -path "*/Library/*" 2>/dev/null)

if [ "$MISSING_META" -eq 0 ]; then
pass "All .cs and .asmdef files have .meta files"
fi

# 2. New folders (staged or untracked) have .meta files
echo ""
echo "--- Folder meta files (new folders only) ---"
MISSING_FOLDER_META=0
while IFS= read -r dir; do
[ -z "$dir" ] && continue
[ ! -d "$dir" ] && continue
dir_meta="${dir}.meta"
if [ ! -f "$dir_meta" ]; then
fail "Missing folder .meta: $dir_meta"
MISSING_FOLDER_META=$((MISSING_FOLDER_META + 1))
fi
done < <(git diff --name-only --diff-filter=A HEAD 2>/dev/null | xargs -I{} dirname {} | sort -u | grep "^Unity/Assets")
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check claims to validate “New folders (staged or untracked)”, but git diff --name-only --diff-filter=A HEAD will not include untracked files/folders. If you want to cover untracked additions too, incorporate git status --porcelain (or similar) and handle both staged and untracked paths; otherwise, adjust the comment/output to match the actual behavior.

Suggested change
done < <(git diff --name-only --diff-filter=A HEAD 2>/dev/null | xargs -I{} dirname {} | sort -u | grep "^Unity/Assets")
done < <({ git diff --name-only --diff-filter=A HEAD 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null; } | xargs -I{} dirname {} | sort -u | grep "^Unity/Assets")

Copilot uses AI. Check for mistakes.

if [ "$MISSING_FOLDER_META" -eq 0 ]; then
pass "All new folders have .meta files"
fi

# 3. No duplicate GUIDs among new/changed .meta files
echo ""
echo "--- GUID uniqueness (new/changed files) ---"
GUID_ERRORS=0
for meta_file in $(git diff --name-only HEAD 2>/dev/null | grep '\.meta$' || true); do
[ ! -f "$meta_file" ] && continue
guid=$(grep "^guid:" "$meta_file" 2>/dev/null | head -1 | awk '{print $2}')
[ -z "$guid" ] && continue
matches=$(find Unity/Assets -name "*.meta" -exec grep -l "^guid: $guid" {} + 2>/dev/null | wc -l)
if [ "$matches" -gt 1 ]; then
Comment on lines +62 to +71
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These checks rely on git diff ... HEAD to detect “new/changed” files. In CI the working tree is typically clean, so git diff HEAD is empty and the folder-meta/GUID-collision checks won’t run against PR changes. Consider diffing against the PR base (e.g., using GITHUB_BASE_REF / merge-base) or accepting a base ref/commit as an argument so the script can validate PR deltas in CI as well as local runs.

Copilot uses AI. Check for mistakes.
fail "$meta_file has duplicate GUID $guid"
GUID_ERRORS=$((GUID_ERRORS + 1))
fi
done

if [ "$GUID_ERRORS" -eq 0 ]; then
pass "No GUID collisions in new/changed .meta files"
fi

# 4. 500-line cap
echo ""
echo "--- 500-line cap ---"
OVER_500=0
while IFS= read -r cs_file; do
lines=$(wc -l < "$cs_file")
if [ "$lines" -gt 500 ]; then
fail "$cs_file is $lines lines (max 500)"
OVER_500=$((OVER_500 + 1))
fi
done < <(find Unity/Assets -name "*.cs" -not -path "*/Library/*" 2>/dev/null)

if [ "$OVER_500" -eq 0 ]; then
pass "All files under 500 lines"
fi

# 5. All using directives in test files reference namespaces available via asmdef
echo ""
echo "--- Namespace references ---"
if [ -f "$RUNTIME_ASMDEF" ]; then
# Check that test files only import TaekwondoTech.* (covered by TT2.Runtime)
# or standard Unity/System namespaces
BAD_IMPORTS=0
while IFS= read -r test_file; do
while IFS= read -r import; do
ns=$(echo "$import" | sed 's/using //;s/;//;s/ //')
case "$ns" in
TaekwondoTech*|UnityEngine*|UnityEditor*|System*|NUnit*|TMPro*) ;;
*) fail "$test_file imports unknown namespace: $ns"
BAD_IMPORTS=$((BAD_IMPORTS + 1)) ;;
esac
done < <(grep "^using " "$test_file" 2>/dev/null)
done < <(find "$TESTS_DIR" -name "*.cs" 2>/dev/null)

if [ "$BAD_IMPORTS" -eq 0 ]; then
pass "All test imports reference known namespaces"
fi
else
warn "SKIP: $RUNTIME_ASMDEF not found — cannot verify namespace references"
fi

# 6. Production asmdef references external packages that code needs
echo ""
echo "--- External package references ---"
if [ -f "$RUNTIME_ASMDEF" ]; then
# Check if any .cs file uses TMPro but asmdef doesn't reference it
USES_TMPRO=$(grep -rl "using TMPro;" "$SCRIPTS_DIR" 2>/dev/null | head -1)
if [ -n "$USES_TMPRO" ]; then
if ! grep -q "Unity.TextMeshPro" "$RUNTIME_ASMDEF"; then
fail "Code uses TMPro but TT2.Runtime.asmdef missing Unity.TextMeshPro reference"
else
pass "TMPro reference present in asmdef"
fi
fi

# Check if any .cs file uses Timeline but asmdef doesn't reference it
USES_TIMELINE=$(grep -rl "using UnityEngine.Timeline;" "$SCRIPTS_DIR" 2>/dev/null | head -1 || true)
if [ -n "$USES_TIMELINE" ]; then
if ! grep -q "Unity.Timeline" "$RUNTIME_ASMDEF"; then
fail "Code uses Timeline but TT2.Runtime.asmdef missing Unity.Timeline reference"
fi
fi
else
warn "SKIP: No runtime asmdef found"
fi

# 7. No Object.Destroy() in test code without LogAssert protection
echo ""
echo "--- EditMode test safety ---"
UNSAFE_DESTROY=0
while IFS= read -r test_file; do
if grep -qn "Object\.Destroy\b" "$test_file" 2>/dev/null; then
if ! grep -q "LogAssert" "$test_file" 2>/dev/null; then
fail "$test_file calls Object.Destroy without LogAssert (will fail in EditMode)"
UNSAFE_DESTROY=$((UNSAFE_DESTROY + 1))
fi
fi
done < <(find "$TESTS_DIR" -name "*.cs" 2>/dev/null || true)

if [ "$UNSAFE_DESTROY" -eq 0 ]; then
pass "No unprotected Object.Destroy in test code"
fi

# Summary
echo ""
echo "==========================="
if [ "$ERRORS" -gt 0 ]; then
red "PREFLIGHT FAILED: $ERRORS error(s)"
exit 1
else
green "PREFLIGHT PASSED"
exit 0
fi
Loading