313 lines
17 KiB
Markdown
313 lines
17 KiB
Markdown
# Phase 3.K.1 — Test harness foundation (DI refactor + pure-logic tests)
|
||
|
||
**Status:** Design, awaiting review
|
||
**Date:** 2026-04-17
|
||
**Parent:** Phase 3, Area K (Test harness). Originally a single P4 task in `2026-04-17-phase3-content-meta-retention.md`; expanded into the K.x series after brainstorming.
|
||
|
||
---
|
||
|
||
## Goal
|
||
|
||
Establish a test harness in the TowerAn project that serves as a **safety net for future refactoring**. Introduce unit tests for pure-logic singletons (`PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`, `TalentData`) so that subsequent extraction work in the `FirstScreen` god-class (planned as spec series K.2–K.7) can proceed without fear of silent regressions.
|
||
|
||
The spec also delivers the architectural prerequisite for testability: **removing the `getInstance()` singleton pattern** from the persistent-state classes, replacing it with constructor injection via a lightweight `GameContext` holder.
|
||
|
||
## Non-goals
|
||
|
||
- Any extraction of logic out of `FirstScreen`. Those are separate specs (K.2 `BulletSystem`, K.3 `SpawnController`, K.4 `WaveController`, K.5 `EnemyBehaviour`, K.6 `AbilitySystem`, K.7 `UpgradeSystem`). K.1 only prepares the ground.
|
||
- Tests for `Achievements` and `DailySeed`. These classes do not exist yet (Phase 3 Tasks G1 / H1); their tests are authored alongside the classes, not in K.1.
|
||
- Tests for enemy behaviour (`BaseCircleData` and subclasses). Deferred to K.5 because the new Phase 3 archetypes (Healer, Dasher, Sniper, Gnat) are not yet implemented.
|
||
- Code coverage gating (JaCoCo). Introducing coverage thresholds without baseline history is premature.
|
||
- CI integration (GitHub Actions or similar). Scope is local `./gradlew test`. CI wiring can be a tiny follow-up once K.1 is merged.
|
||
- Headless libGDX (`HeadlessApplication`). Not needed because the DI refactor lets tests supply an in-memory `Preferences` directly.
|
||
|
||
## Background
|
||
|
||
TowerAn is a libGDX 1.13.1 game on Java 8, gdx-liftoff template, flat `ru.project.tower` package. There are currently **no tests of any kind** — no JUnit, no Spock, no manual harness. Verification has been purely visual: launch `Lwjgl3Launcher`, play, watch.
|
||
|
||
Five classes hold persistent or shared state through a static-singleton pattern:
|
||
|
||
- `PlayerStats` — currency, max wave, talent node levels. Backed by `Preferences` (`tower_attack_player_data`). `getInstance()` eagerly calls `Gdx.app.getPreferences(...)`.
|
||
- `PlayerAbilities` — unlocked abilities, levels. Backed by `Preferences` (`tower_attack_player_abilities`). Same pattern.
|
||
- `GameState` — in-memory run state (current wave, square health, money, run bonuses). No `Preferences`.
|
||
- `TalentTree` — tree structure + level queries that call `PlayerStats.getInstance()` internally (lines 133, 138).
|
||
- `TalentData` — plain data class used by `TalentTree`. No singleton; already testable but currently untested.
|
||
|
||
`getInstance()` usage is concentrated in five files (15 calls): `AbilitySelectionScreen` (2), `AbilityShopScreen` (2), `FirstScreen` (8), `TalentTreeScreen` (1), `TalentTree` (2).
|
||
|
||
Because singletons eagerly touch `Gdx.app` and `Preferences` on first `getInstance()`, any test that instantiates them fails outside a running libGDX context. The options are (a) reflection-nuking the `instance` field between tests, (b) `@VisibleForTesting resetForTests()` helpers, or (c) breaking the pattern. We chose (c) — it's a one-time architectural change that makes tests clean and unlocks downstream `FirstScreen` extraction work in K.2+.
|
||
|
||
## Architecture overview
|
||
|
||
Three layers, implemented in this order (later layers depend on earlier ones):
|
||
|
||
1. **DI refactor.** Delete `private static instance` and `public static getInstance()` from `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`. Make constructors public and accept their dependencies explicitly.
|
||
2. **Composition root.** Introduce `GameContext` as a holder for the refactored singletons. `Main.create()` constructs two `Preferences` instances, wraps them in a `GameContext`, and passes the `GameContext` to each screen via constructor injection.
|
||
3. **Test harness.** Add JUnit 5 + AssertJ to `core/build.gradle`. Create an in-memory `FakePreferences` helper in `core/src/test/java`. Write unit tests for the five target classes.
|
||
|
||
Nothing in the rendering layer, the screen hierarchy, the enemy hierarchy, or the gameplay loop changes beyond mechanical `X.getInstance()` → `ctx.x` substitutions.
|
||
|
||
## Components
|
||
|
||
### `GameContext` (new, `core/src/main/java/ru/project/tower/GameContext.java`)
|
||
|
||
A tiny holder with `public final` fields, idiomatic Java for an immutable data carrier (Google Java Style §5.3 allows `public final` for this role).
|
||
|
||
```java
|
||
public class GameContext {
|
||
public final Preferences playerDataPrefs;
|
||
public final Preferences abilitiesPrefs;
|
||
public final PlayerStats playerStats;
|
||
public final PlayerAbilities playerAbilities;
|
||
public final GameState gameState;
|
||
public final TalentTree talentTree;
|
||
|
||
public GameContext(Preferences playerDataPrefs, Preferences abilitiesPrefs) {
|
||
this.playerDataPrefs = playerDataPrefs;
|
||
this.abilitiesPrefs = abilitiesPrefs;
|
||
this.playerStats = new PlayerStats(playerDataPrefs);
|
||
this.playerAbilities = new PlayerAbilities(abilitiesPrefs);
|
||
this.gameState = new GameState();
|
||
this.talentTree = new TalentTree(this.playerStats);
|
||
}
|
||
}
|
||
```
|
||
|
||
Construction order matters: `TalentTree` depends on `PlayerStats`, so `playerStats` is created first. `Preferences` references are retained on the holder so future services (e.g., `Achievements`, `DailySeed` in Phase 3 G/H) can reuse them without re-plumbing `Main`.
|
||
|
||
### Singleton-to-DI conversions
|
||
|
||
For each target, the change is the same mechanical shape. Example for `PlayerStats`:
|
||
|
||
```java
|
||
// Before
|
||
private static PlayerStats instance;
|
||
private Preferences preferences;
|
||
|
||
private PlayerStats() {
|
||
preferences = Gdx.app.getPreferences(PREFS_NAME);
|
||
loadData();
|
||
}
|
||
|
||
public static PlayerStats getInstance() {
|
||
if (instance == null) instance = new PlayerStats();
|
||
return instance;
|
||
}
|
||
|
||
// After
|
||
private final Preferences preferences;
|
||
|
||
public PlayerStats(Preferences preferences) {
|
||
this.preferences = preferences;
|
||
loadData();
|
||
}
|
||
```
|
||
|
||
The private `PREFS_NAME = "tower_attack_player_data"` constant is deleted; the same literal moves to `Main` as `private static final String PLAYER_DATA_PREFS_NAME`. `PlayerAbilities` gets analogous treatment with `ABILITIES_PREFS_NAME`.
|
||
|
||
`GameState` has no `Preferences`, so its constructor is simply made public and argumentless. `TalentTree(PlayerStats stats)` stores the reference and replaces the two `PlayerStats.getInstance()` calls at lines 133 / 138 with `this.stats.getTalentNodeLevel(...)` and `this.stats.incrementTalentNode(...)`.
|
||
|
||
### `Main` as composition root
|
||
|
||
```java
|
||
public class Main extends Game {
|
||
private static final String PLAYER_DATA_PREFS_NAME = "tower_attack_player_data";
|
||
private static final String ABILITIES_PREFS_NAME = "tower_attack_player_abilities";
|
||
|
||
public GameContext ctx;
|
||
|
||
@Override
|
||
public void create() {
|
||
ctx = new GameContext(
|
||
Gdx.app.getPreferences(PLAYER_DATA_PREFS_NAME),
|
||
Gdx.app.getPreferences(ABILITIES_PREFS_NAME)
|
||
);
|
||
setScreen(new MainScreen(this, ctx));
|
||
}
|
||
}
|
||
```
|
||
|
||
`ctx` is a public field on `Main` so screens that already hold a `Main` reference can read it if needed, but the preferred path is receiving `GameContext` in the constructor.
|
||
|
||
### Screen constructor changes
|
||
|
||
Every screen class currently instantiated with `new Xxx(game)` becomes `new Xxx(game, ctx)`:
|
||
|
||
- `MainScreen(Main game, GameContext ctx)`
|
||
- `FirstScreen(Main game, GameContext ctx)`
|
||
- `AbilityShopScreen(Main game, GameContext ctx)`
|
||
- `AbilitySelectionScreen(Main game, GameContext ctx)`
|
||
- `TalentTreeScreen(Main game, GameContext ctx)`
|
||
|
||
Every `X.getInstance()` call becomes `ctx.x`. In `FirstScreen`, where there are 8 calls, cache into fields (`private final PlayerStats playerStats;` etc.) during the constructor to avoid `ctx.playerStats` repetition.
|
||
|
||
### `FakePreferences` (test helper, `core/src/test/java/ru/project/tower/FakePreferences.java`)
|
||
|
||
An in-memory implementation of `com.badlogic.gdx.Preferences` backed by `HashMap<String, Object>`. Approximately 30 trivial methods — every `putX(k, v)` stores in the map, every `getX(k)` / `getX(k, default)` reads. `flush()` is a no-op. `clear()` clears the map. `contains(k)` delegates to `map.containsKey`.
|
||
|
||
One shared helper serves every test class. Chosen over Mockito because Mockito for a single deterministic interface adds a dependency and weaker error messages for a trivial case.
|
||
|
||
## Data flow
|
||
|
||
**Production:**
|
||
```
|
||
Main.create()
|
||
└─ creates two Preferences via Gdx.app.getPreferences(...)
|
||
└─ new GameContext(playerDataPrefs, abilitiesPrefs)
|
||
└─ setScreen(new MainScreen(this, ctx))
|
||
│
|
||
└─ ctx propagates to every downstream screen constructor
|
||
└─ screens access ctx.playerStats, ctx.talentTree, etc.
|
||
```
|
||
|
||
**Tests:**
|
||
```
|
||
@BeforeEach
|
||
└─ new FakePreferences()
|
||
└─ new PlayerStats(fakePrefs) // or the class under test, injected with fakes
|
||
@Test
|
||
└─ AAA: arrange state, invoke method, assert outcome
|
||
(both on the object's API and on the fake Preferences where persistence matters)
|
||
```
|
||
|
||
## Test conventions
|
||
|
||
- **Framework:** JUnit 5 (`org.junit.jupiter:junit-jupiter:5.10.2`).
|
||
- **Assertions:** AssertJ (`org.assertj:assertj-core:3.26.3`). All assertions use `assertThat(...)` — no `assertEquals`. Rationale: fluent API, clearer messages, industry standard in modern Java.
|
||
- **Test-method naming:** `methodName_whenCondition_thenExpected`. Sorts naturally by method in IDE, reads like a sentence.
|
||
- `incrementTalentNode_whenNewNode_thenStoredInPreferences`
|
||
- `getCurrency_whenNotYetSet_thenReturnsZero`
|
||
- **Test-class naming:** `<ClassUnderTest>Test` (e.g., `PlayerStatsTest.java`).
|
||
- **Body structure:** Arrange / Act / Assert separated by blank lines. No `// Arrange` comments.
|
||
- **Isolation:** JUnit 5's default new-instance-per-test + `@BeforeEach` for setup. No static state between tests.
|
||
- **One behaviour per test.** If a test has three `assertThat(...)` lines, they should all be aspects of the same behaviour (e.g., state returned _and_ persisted). Don't bundle unrelated scenarios.
|
||
|
||
## Test coverage targets
|
||
|
||
Approximately 30–35 test methods across 6 test classes, as sketched below. This is a floor, not a ceiling — prefer covering each public-API method with at least one happy-path test and one edge case.
|
||
|
||
### `PlayerStatsTest`
|
||
- `getCurrency_whenNotYetSet_thenReturnsZero`
|
||
- `addCurrency_whenCalled_thenIncrementsAndPersists`
|
||
- `spendCurrency_whenInsufficient_thenReturnsFalseAndDoesNotDeduct`
|
||
- `spendCurrency_whenSufficient_thenReturnsTrueAndDeducts`
|
||
- `getMaxWaveReached_thenReadsFromPreferences`
|
||
- `updateMaxWaveReached_whenNewHigher_thenUpdates`
|
||
- `updateMaxWaveReached_whenNewLower_thenIgnored`
|
||
- `getTalentNodeLevel_whenUnseen_thenZero`
|
||
- `incrementTalentNode_whenCalled_thenLevelOneAndPersisted`
|
||
- `incrementTalentNode_whenCalledTwice_thenLevelTwo`
|
||
- Legacy aggregate getter(s): one test covering the wrapper still reports the right total.
|
||
- A test that constructs `PlayerStats` from pre-populated `FakePreferences` and reads back expected state (persistence round-trip).
|
||
|
||
### `PlayerAbilitiesTest`
|
||
- Every ability-unlock flag, get/set round-trip through `FakePreferences`.
|
||
- Unlock toggles don't affect unrelated flags.
|
||
- Default-state test (fresh `FakePreferences` gives the expected initial roster).
|
||
|
||
### `GameStateTest`
|
||
- Initial values (wave, square health, money).
|
||
- Wave increment.
|
||
- Phase 2 `run*` bonus fields (damage, pierce, etc.) accumulate correctly.
|
||
- Reset-for-new-run behaviour. The implementation plan must first confirm whether `GameState` has a reset API; if not, the plan adds a small one as part of this spec (this is a test-enabling concession, not feature work).
|
||
|
||
### `TalentTreeTest`
|
||
- `initializeTree` creates expected roots and node IDs.
|
||
- `getTalentLevel(id)` delegates to injected `PlayerStats`.
|
||
- `incrementTalent(id)` delegates to injected `PlayerStats`.
|
||
- Keystone node IDs (`keystone_pierce`, `keystone_crit`, `keystone_aoe`) are all registered.
|
||
|
||
### `TalentDataTest`
|
||
- Construction: id, name, description, type, position, max level, base cost, cost multiplier all round-trip via getters.
|
||
- Cost-at-level formula round-trip. The exact formula is read from the current `TalentData` source during implementation; the test locks in whatever that formula is, not a newly-invited one.
|
||
|
||
### `GameContextTest`
|
||
- Construction with two `FakePreferences` yields non-null services.
|
||
- `ctx.talentTree.getTalentLevel(someId)` reads from the same `PlayerStats` that `ctx.playerStats` exposes (i.e., wiring is correct).
|
||
- Construction order: `TalentTree` sees the injected `PlayerStats`, not a new one.
|
||
|
||
## Build configuration
|
||
|
||
`core/build.gradle`:
|
||
|
||
```gradle
|
||
dependencies {
|
||
api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion"
|
||
api "com.badlogicgames.gdx:gdx:$gdxVersion"
|
||
|
||
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
|
||
testImplementation 'org.assertj:assertj-core:3.26.3'
|
||
|
||
if (enableGraalNative == 'true') {
|
||
implementation "io.github.berstanio:gdx-svmhelper-annotations:$graalHelperVersion"
|
||
}
|
||
}
|
||
|
||
test {
|
||
useJUnitPlatform()
|
||
}
|
||
```
|
||
|
||
Java 8 compatibility is preserved: JUnit 5.10 and AssertJ 3.26 both still ship Java-8-compatible bytecode.
|
||
|
||
## File layout
|
||
|
||
```
|
||
core/src/
|
||
main/java/ru/project/tower/
|
||
GameContext.java [NEW]
|
||
PlayerStats.java [MODIFIED — constructor + remove getInstance]
|
||
PlayerAbilities.java [MODIFIED — constructor + remove getInstance]
|
||
GameState.java [MODIFIED — constructor public + remove getInstance]
|
||
TalentTree.java [MODIFIED — constructor + remove getInstance]
|
||
Main.java [MODIFIED — composition root]
|
||
MainScreen.java [MODIFIED — constructor takes ctx]
|
||
FirstScreen.java [MODIFIED — constructor takes ctx, replace 8 getInstance]
|
||
AbilityShopScreen.java [MODIFIED — constructor takes ctx]
|
||
AbilitySelectionScreen.java [MODIFIED — constructor takes ctx]
|
||
TalentTreeScreen.java [MODIFIED — constructor takes ctx]
|
||
test/java/ru/project/tower/ [NEW TREE]
|
||
FakePreferences.java
|
||
PlayerStatsTest.java
|
||
PlayerAbilitiesTest.java
|
||
GameStateTest.java
|
||
TalentTreeTest.java
|
||
TalentDataTest.java
|
||
GameContextTest.java
|
||
```
|
||
|
||
## Error handling
|
||
|
||
There is one edge where the refactor can silently change behaviour: `getInstance()` lazily creates the singleton on first call. A class that was instantiated *before* `Gdx.app` was ready (unlikely in this codebase but worth naming) would now fail eagerly at `Main.create()` instead of at first use. Since `Main.create()` is the libGDX entry point and `Gdx.app` is guaranteed set by the backend before `create()` runs, this is safe.
|
||
|
||
All other error paths are unchanged — `Preferences.putX`/`getX` throw the same exceptions; `loadData()` runs the same logic.
|
||
|
||
## Verification
|
||
|
||
Because K.1 introduces the test harness itself, verification is both **automated** (the tests) and **manual** (the DI refactor must not break the running game):
|
||
|
||
- `./gradlew test` runs cleanly, all tests pass.
|
||
- Launch `Lwjgl3Launcher`. Confirm:
|
||
- Main menu renders; currency and max-wave values read from existing preferences.
|
||
- Entering the talent tree shows purchased levels unchanged.
|
||
- Starting a run, buying an upgrade, dying, and returning to menu preserves currency.
|
||
- Ability shop displays owned abilities correctly.
|
||
|
||
Any of these failing indicates the `getInstance()` → `ctx.x` substitution dropped a wire.
|
||
|
||
## Risks and mitigations
|
||
|
||
- **Missed call-site.** 15 `getInstance()` calls plus possibly some we didn't count (constants, static initializers). Mitigation: after the refactor, `grep -r "getInstance()" core/src/main` must return zero hits. Automated in implementation plan.
|
||
- **Screen-constructor signature changes break something external.** `lwjgl3/` and `android/` only touch `Main`, not individual screens. Mitigation: grep across all modules for `new MainScreen(`, `new FirstScreen(`, etc. after the refactor.
|
||
- **Hidden `Preferences`-name constants.** We found two (`tower_attack_player_data`, `tower_attack_player_abilities`). If Phase 3 work introduces a third, the spec still works — just add another `Preferences` parameter to `GameContext`. Not a risk for K.1 itself.
|
||
- **Test flakiness from order dependence.** Prevented by JUnit 5 per-test-instance default + fresh `FakePreferences` in `@BeforeEach`.
|
||
|
||
## Out of scope (reminder)
|
||
|
||
K.1 delivers the harness and proves the pattern on five pure-logic classes. Every subsequent K.x spec (K.2–K.7) builds on this to extract and test real gameplay subsystems. K.1 is merged when:
|
||
|
||
1. All 15+ `getInstance()` calls are gone.
|
||
2. `GameContext` is the sole composition point for persistent services.
|
||
3. `./gradlew test` runs and passes all 30+ tests authored in this spec.
|
||
4. The game still boots and plays correctly on the desktop launcher.
|