mirror of
https://github.com/motajs/template.git
synced 2026-09-20 06:20:17 +08:00
test(06-01): stage 1 component-level EnemyContext coverage
- cover every public EnemyContext method (registry, converters, effect registrations, attachments, dirty, lifecycle) - apply single fake auras over Full/Rect/Manhattan ranges and assert out-of-range enemies stay unchanged - add shared 06-COVERAGE-MAP.md skeleton with the code -> module -> case header protocol
This commit is contained in:
parent
ef5e4e9180
commit
2e4e6d739d
@ -1,183 +0,0 @@
|
||||
---
|
||||
phase: 06-unit-tests
|
||||
plan: 01
|
||||
subsystem: testing
|
||||
tags: [vitest, unit-test, combat, damage, map-damage, enemy-context, combat-flow, data-system, node]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "Layer-2 combat behavior coverage: DamageContext/DamageSystem, MapDamage, EnemyContext, CombatFlow"
|
||||
- "Node combat test harness pattern (vi.hoisted global stub + dynamic module bag + inline fakes + real-timer await)"
|
||||
- "COVERAGE.md api-coverage declaration and the shared 06-TEST-FINDINGS.md sink with #06-01-1..3 findings"
|
||||
affects: [06-unit-tests, 06-02, 06-03, 06-08]
|
||||
|
||||
actuals:
|
||||
tokens: 15725
|
||||
tasks: 3
|
||||
commits: 6
|
||||
plan_head_before: 3bcf2fc40761989238aeacfdbeb5b6c84a64642b
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "vi.hoisted global stub (main/location + Map.getOrInsert/getOrInsertComputed polyfill) + beforeAll dynamic module bag"
|
||||
- "Inline per-file fake collaborators; data-layer interfaces are never mocked"
|
||||
- "Real timers + manual deferred resolver for async combat ordering; logger.catch for warn-code assertions"
|
||||
- "Suspected bug -> correct-expectation test marked it.skip plus a Chinese pointer to 06-TEST-FINDINGS.md #06-01-N"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- packages-user/data-system/src/combat/damage.test.ts
|
||||
- packages-user/data-system/src/combat/mapDamage.test.ts
|
||||
- packages-user/data-system/src/combat/context.test.ts
|
||||
- packages-user/data-system/src/combat/combat.test.ts
|
||||
- .planning/phases/06-unit-tests/COVERAGE.md
|
||||
- .planning/phases/06-unit-tests/06-TEST-FINDINGS.md
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "TEST-01 contribution for this plan = behavior unit tests over the combat Layer-2 contract, gated by `pnpm test:ci` green (D-01/D-08)"
|
||||
- "Three suspected bugs are recorded, not fixed: calculateCritical info/nextValue mismatch; MapDamage.deleteEnemy leaves sourced damage; CombatFlow.before truthy/falsy semantics inverted vs the interface doc"
|
||||
- "Map.getOrInsert polyfill added alongside getOrInsertComputed because EnemyContext uses it under Node"
|
||||
- "Effect-registry add/remove is asserted through observable buildup behavior because the registries have no public getter"
|
||||
|
||||
requirements-completed: [TEST-01]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "DamageContext/DamageSystem behavior coverage (result spread, handler identity, warns 106/107, cache hit/invalidation, with(hero), calculateCritical)"
|
||||
requirement: TEST-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "packages-user/data-system/src/combat/damage.test.ts#DamageContext behaviour"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "MapDamage behavior coverage (sourceless add/delete, sourced conversion, reduction caching, union, warns 102/103/104)"
|
||||
requirement: TEST-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "packages-user/data-system/src/combat/mapDamage.test.ts#MapDamage sourced conversion and reduction"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "EnemyContext behavior coverage (register/lookup/unknown-null/delete/resize/scan/iterate, effect-registry add/remove)"
|
||||
requirement: TEST-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "packages-user/data-system/src/combat/context.test.ts#EnemyContext registry"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "CombatFlow behavior coverage (same-state binding, warn 138, script priority sort/duplicate 140, guard warns 139/141, awaited ordering, truthy short-circuit)"
|
||||
requirement: TEST-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "packages-user/data-system/src/combat/combat.test.ts#CombatFlow async ordering"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Suspected-bug findings #06-01-1..3 triaged by the user (fix core code, amend interface contract, or defer)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "The three skipped tests encode the expected contract against the current implementation; per D-05/D-07 only the user can decide whether to repair core code or amend the interface contract, and no core source is modified in this phase."
|
||||
|
||||
duration: 9min
|
||||
completed: 2026-09-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 06 Plan 01: Combat Layer-2 Unit Tests Summary
|
||||
|
||||
**Behavior unit tests for the `data-system` combat Layer-2 contract (DamageContext/DamageSystem, MapDamage, EnemyContext, CombatFlow) proven end-to-end in Node, surfaced 3 suspected bugs**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 9 min
|
||||
- **Started:** 2026-09-14T00:43:36Z
|
||||
- **Completed:** 2026-09-14T00:52:20Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 6 created (4 test files + 2 planning artifacts), 0 production files touched
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Tracer proved the whole Node combat test path (`vi.hoisted` global stub, dynamic module bag, inline fake `IEnemyContext`/`IDamageCalculator`, `logger.catch` warn assertions, real-timer async) on `damage.test.ts` before any expansion.
|
||||
- Full Layer-2 combat behavior covered: warn codes 106/107 (damage), 102/103/104 (map damage), 138/139/140/141 (combat flow), plus cache invalidation, `with(hero)`, critical generation and the documented script/hook execution order.
|
||||
- Opened the shared `06-TEST-FINDINGS.md` sink with the D-06 schema and recorded three suspected bugs (`#06-01-1`..`#06-01-3`) as correct-expectation `it.skip` tests; `pnpm test:ci` stayed green (23 files, 153 passed, 3 documented skips).
|
||||
- Declared `COVERAGE.md`: this test-only phase adds no external API integration.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Tracer — DamageContext/DamageSystem behavior end-to-end (+ phase scaffolding)** - `59b709e` (test)
|
||||
2. **Task 1 harness fix: Map.getOrInsert polyfill for the combat test harness** - `15f329b` (test)
|
||||
3. **Task 2: MapDamage + EnemyContext behavior** - `484da2d` (test)
|
||||
4. **Task 3: CombatFlow binding, script ordering, and battle flow** - `6c8f85a` (test)
|
||||
|
||||
**Plan metadata:** (final docs commit, recorded in STATE.md/ROADMAP.md)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `packages-user/data-system/src/combat/damage.test.ts` - DamageContext/DamageSystem behavior + calculateCritical
|
||||
- `packages-user/data-system/src/combat/mapDamage.test.ts` - MapDamage sourceless/sourced damage, reducer caching, warns 102/103/104
|
||||
- `packages-user/data-system/src/combat/context.test.ts` - EnemyContext registry, resize/scan/iterate, effect-registry add/remove
|
||||
- `packages-user/data-system/src/combat/combat.test.ts` - CombatFlow binding, script ordering/duplication, guard warns, awaited order
|
||||
- `.planning/phases/06-unit-tests/COVERAGE.md` - api-coverage declaration for the test-only phase
|
||||
- `.planning/phases/06-unit-tests/06-TEST-FINDINGS.md` - shared append-only findings sink with the `#06-01` section
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Treated TEST-01 as satisfied by behavior tests over the seven in-scope data-layer systems, gated by `pnpm test:ci` (the plan's flagged, unverified assumption).
|
||||
- Wrote the three suspected-bug tests to the correct expectation and marked them `it.skip` (never weakened into passing), each with a Chinese comment pointing at its `#06-01-N` findings entry.
|
||||
- Asserted effect-registry add/remove through observable `buildup()` behavior instead of a public getter, because `EnemyContext` exposes no registry accessors.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Missing `Map.getOrInsert` polyfill under Node**
|
||||
|
||||
- **Found during:** Task 2 (EnemyContext effect registries)
|
||||
- **Issue:** `EnemyContext.registerCommonQueryEffect` / `registerSpecialQueryEffect` call `Map.prototype.getOrInsert`, which does not exist in Node; only `getOrInsertComputed` was polyfilled by the existing harness pattern, so `context.test.ts` failed to load.
|
||||
- **Fix:** Added a `Map.prototype.getOrInsert` stub next to the existing `getOrInsertComputed` stub in the combat test harness (uniform across all four files).
|
||||
- **Files modified:** `context.test.ts`, `damage.test.ts` (harness-only)
|
||||
- **Verification:** Focused vitest run green; full `pnpm test:ci` green.
|
||||
- **Committed in:** `15f329b` (harness fix), folded into `484da2d`/`6c8f85a`
|
||||
|
||||
**2. [Rule 3 - Blocking] Cannot `extends` a dynamically imported class**
|
||||
|
||||
- **Found during:** Task 2 (mapDamage fixture)
|
||||
- **Issue:** `class FakeRange extends modules.BaseRange` evaluated at module load, before `beforeAll` assigned the dynamic module bag, so `modules.BaseRange` was `undefined`.
|
||||
- **Fix:** Replaced the `BaseRange` subclass with a small local `IFakeRange` implementation exposing only `bindHost`/`iterateLoc`, with a single narrow cast in `FakeView.getRange()`.
|
||||
- **Files modified:** `mapDamage.test.ts`
|
||||
- **Verification:** Focused vitest run green for `mapDamage.test.ts`.
|
||||
- **Committed in:** `484da2d` (Task 2 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (2 blocking)
|
||||
**Impact on plan:** Both were test-harness-only fixes; no production source changed and no scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Commit staging: the first Task 2 commit accidentally swept in the Task 2 files under a harness-fix message. Corrected with `git reset --soft HEAD~1` (no working-tree change) and re-committed the harness fix and Task 2 separately.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. Three `it.skip` tests are intentional D-05 markers for suspected bugs, tracked in `06-TEST-FINDINGS.md` (`#06-01-1` calculateCritical `info`/`nextValue` mismatch; `#06-01-2` `MapDamage.deleteEnemy` leaves sourced damage because `viewStore`/`damageStore` are never populated; `#06-01-3` `CombatFlow` short-circuits on truthy `before`, contradicting the interface doc's `false` semantics).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- The combat Layer-2 contract is covered and green; later plans (06-02 `data-state/src/enemy`, 06-03 `data-base/src/enemy`, 06-08 `CoreState` integration) can reuse the established harness and append their own `#06-0X` findings sections.
|
||||
- Open input for the user: triage the three `#06-01-N` findings (fix core code vs. amend the interface contract). Per D-07 no core code was modified in this phase.
|
||||
|
||||
---
|
||||
*Phase: 06-unit-tests*
|
||||
*Completed: 2026-09-14*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files verified present: `damage.test.ts`, `mapDamage.test.ts`, `context.test.ts`, `combat.test.ts`, `COVERAGE.md`, `06-TEST-FINDINGS.md`, `06-01-SUMMARY.md`.
|
||||
- Task commits verified in history: `59b709e`, `15f329b`, `484da2d`, `6c8f85a`, plus metadata `6cf828c`.
|
||||
- Focused runs and full `pnpm test:ci` green (23 files, 153 passed, 3 documented skips).
|
||||
@ -1,179 +0,0 @@
|
||||
---
|
||||
phase: 06-unit-tests
|
||||
plan: 02
|
||||
subsystem: testing
|
||||
tags: [vitest, unit-test, data-state, enemy, damage-calculator, aura, special-registry, map-damage, node]
|
||||
|
||||
requires:
|
||||
- phase: 06-unit-tests
|
||||
provides: "Node Vitest harness pattern (vi.hoisted global stub + dynamic module bag + inline fakes + logger.catch) and the shared 06-TEST-FINDINGS.md sink"
|
||||
provides:
|
||||
- "Top-level combat implementation coverage: MainDamageCalculator (all special branches), MainEnemyFinalEffect, MainEnemyComparer, CommonAura/GuardAura, registerSpecials, and the map-damage views/converter/reducer"
|
||||
- "Reusable inline fixture shapes for data-state enemy tests (fake enemy/hero/state/context/view)"
|
||||
affects: [06-unit-tests, 06-03, 06-08]
|
||||
|
||||
actuals:
|
||||
tokens: 16356
|
||||
tasks: 3
|
||||
commits: 4
|
||||
plan_head_before: 50d868760aab1f00544424d01b1982978d0aa344
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Inline per-file fake IEnemy/IReadonlyEnemy/IReadonlyHeroAttribute/IStateBase/IEnemyContext; data-layer interfaces never mocked"
|
||||
- "vi.stubGlobal('core', { flags }) with a mutable handle returned from vi.hoisted for the BetweenDamageView core read (restored per assertion)"
|
||||
- "Focused vitest run per task, then pnpm test:ci as the phase gate"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- packages-user/data-state/src/enemy/calculator.test.ts
|
||||
- packages-user/data-state/src/enemy/final.test.ts
|
||||
- packages-user/data-state/src/enemy/comparer.test.ts
|
||||
- packages-user/data-state/src/enemy/aura.test.ts
|
||||
- packages-user/data-state/src/enemy/special.test.ts
|
||||
- packages-user/data-state/src/enemy/mapDamage.test.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "06-02:enemy 顶层实现以行为单测覆盖,6 个同目录 *.test.ts 全部保持 inline fixture(D-02/D-03),未修改任何生产代码"
|
||||
- "06-02:MainDamageCalculator 逐个特殊分支断言精确 { damage, turn },支援路径额外验证 inGuard 不泄漏(两次顶层计算相等 + 支援怪自身 guard 不被递归)"
|
||||
- "06-02:registerSpecials 断言代码 0-27 各注册一次且守卫默认值为空集合;仅对 6/25/1 调用 getSpecialName/getDescription,避免触发 core.values.* 全局读取"
|
||||
- "06-02:plan 对 BetweenDamageView 的描述『两个 delta 都为正』与实现不符(实现是排除向左/向上以去重),按实现实际语义断言(右/下命中),属描述措辞而非代码缺陷,未登记 findings"
|
||||
- "06-02:未发现疑似 bug,06-TEST-FINDINGS.md 无新增 #06-02-N 条目,无 it.skip/it.todo"
|
||||
|
||||
requirements-completed: [TEST-01]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "MainDamageCalculator 行为覆盖:基础伤害/回合、无法破防与无敌、魔攻、连击 4/5、多段、支援递归与告警 137、先攻、破甲、反击、净化、吸血(含 add)、负伤 flag 夹取、固伤、仇恨、取整、攻击/其他属性临界上界"
|
||||
requirement: TEST-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "packages-user/data-state/src/enemy/calculator.test.ts#MainDamageCalculator base and defeat branches"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "MainEnemyFinalEffect 与 MainEnemyComparer 行为覆盖:坚固下限提升、模仿复制、无属性不变、优先级 0;基础属性与特殊属性数量/代码/数值深比较"
|
||||
requirement: TEST-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "packages-user/data-state/src/enemy/final.test.ts#MainEnemyFinalEffect"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "packages-user/data-state/src/enemy/comparer.test.ts#MainEnemyComparer"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "光环与特殊属性注册覆盖:CommonAura 范围选择与加成结算、GuardAura 3x3 范围与守卫定位符添加/自身跳过/缺来源跳过;registerSpecials 代码 0-27 各一次、守卫默认值、名称与描述生成"
|
||||
requirement: TEST-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "packages-user/data-state/src/enemy/aura.test.ts#CommonAura apply"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "packages-user/data-state/src/enemy/special.test.ts#registerSpecials"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "地图伤害覆盖:领域/阻击/激光/夹击/捕捉五种视图的范围参数与伤害信息、转换器按特殊属性顺序组装视图与激光朝向查找、合并器求和/最大伤害类型/额外信息并集"
|
||||
requirement: TEST-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "packages-user/data-state/src/enemy/mapDamage.test.ts#MainMapDamageReducer"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "FLAGGED ASSUMPTION:TEST-01 是否由 `pnpm test:ci` 门禁下的行为单测满足,需用户确认(ROADMAP 侧无边界 verifier 解析该规格)"
|
||||
requirement: TEST-01
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "计划 frontmatter 明确将该假设标为 unverified;只有用户能确认该验收口径,自动化测试无法自证需求定义。"
|
||||
|
||||
duration: 9min
|
||||
completed: 2026-09-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 06 Plan 02: Enemy Top-Level Implementation Unit Tests Summary
|
||||
|
||||
**Behavior unit tests for `data-state/src/enemy` — MainDamageCalculator every special branch, final effect, comparer, aura converters, special registry 0-27, and the five map-damage views plus converter/reducer, all green under the fixed `pnpm test:ci` gate**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 9 min
|
||||
- **Started:** 2026-09-14T08:58:34Z
|
||||
- **Completed:** 2026-09-14T09:07:44Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 6 created (all `*.test.ts`), 0 production files touched
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `calculator.test.ts` (22 tests) proves exact `{ damage, turn }` for the baseline, the two defeat paths (no break-through / 无敌 without cross), and every special branch: 魔攻, 2/3连击, 多段, 支援 guard recursion + warn 137 + `inGuard` non-leakage, 先攻, 破甲, 反击, 净化, 吸血 (with/without `add`), 负伤 flag clamping, 固伤, 仇恨, flooring, and both `getCriticalLimit` paths (坚固 → Infinity).
|
||||
- `aura.test.ts` (13 tests) covers `CommonAuraConverter`/`GuardAuraConverter` selection, `FullRange`/`RectRange`/`ManhattanRange` choice and params, buff math, the 3×3 guard range, guard-locator registration, own-locator skip and missing-source no-op.
|
||||
- `special.test.ts` (6 tests) calls `registerSpecials` on an inline fake manager and asserts codes 0-27 registered exactly once, the `guard` empty-set default, and name/description generation for a serializable special (code 6), the halo (code 25) and a none-property special (code 1).
|
||||
- `mapDamage.test.ts` (19 tests) drives all five views (`Zone`, `Repulse`, `Laser`, `Between`, `Ambush`), the converter's per-special view order + laser face lookup, and the reducer's sum / max-damage type / catch+repulse union, including the `core.flags.betweenAttackMax` branch.
|
||||
- `final.test.ts` (5) and `comparer.test.ts` (5) cover the final-effect and comparison contracts.
|
||||
- Full suite: `pnpm test:ci` → **29 files, 223 passed, 3 skipped** (the 3 skips are the pre-existing `#06-01-N` findings). No new skips.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: MainDamageCalculator + final effect + comparer** - `5b76cf4` (test)
|
||||
2. **Task 2: Auras + special registration** - `b36a4ee` (test)
|
||||
3. **Task 3: Map damage views, converter, reducer** - `7b95670` (test)
|
||||
|
||||
**Plan metadata:** metadata docs commit follows this SUMMARY (3 task commits + 1 metadata commit = 4 total from `plan_head_before`).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `packages-user/data-state/src/enemy/calculator.test.ts` - MainDamageCalculator branch coverage (22 tests)
|
||||
- `packages-user/data-state/src/enemy/final.test.ts` - MainEnemyFinalEffect (5 tests)
|
||||
- `packages-user/data-state/src/enemy/comparer.test.ts` - MainEnemyComparer (5 tests)
|
||||
- `packages-user/data-state/src/enemy/aura.test.ts` - CommonAura/GuardAura + converters (13 tests)
|
||||
- `packages-user/data-state/src/enemy/special.test.ts` - registerSpecials registry (6 tests)
|
||||
- `packages-user/data-state/src/enemy/mapDamage.test.ts` - map damage views/converter/reducer (19 tests)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Kept every fixture inline per file (D-03); no cross-file factory helper was introduced.
|
||||
- Extended the 06-01 harness shape (`vi.hoisted` global/`Map` polyfill stubs + `beforeAll` dynamic module bag + `logger.catch` for warn 137) and added a mutable `core` stub for the `BetweenDamageView` global read.
|
||||
- Asserted `BetweenDamageView` by its actual semantics — the right/down adjacent locator qualifies and left/up is excluded (an anti-double-count bias), rather than the plan's parenthetical "both deltas positive".
|
||||
- No suspected bugs surfaced, so `06-TEST-FINDINGS.md` gains no `#06-02-N` entry and no `it.skip`/`it.todo` was added (D-05 not triggered).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written, except for one documentation clarification recorded below (no code change and no test weakening).
|
||||
|
||||
### Clarification (not a code deviation)
|
||||
|
||||
**BetweenDamageView qualifier wording**
|
||||
- **Found during:** Task 3 (map damage views)
|
||||
- **Plan text:** "only the forward-adjacent locator (delta sum 1 and both deltas positive) qualifies".
|
||||
- **Implementation:** rejects only `deltaX <= 0 && deltaY <= 0`, so the right `(dx=1,dy=0)` and down `(dx=0,dy=1)` neighbours qualify while left/up do not. This deliberate direction bias prevents the mirrored source enemy from double-counting the same pair.
|
||||
- **Action:** Tests assert the implementation's actual (correct) semantics; no `it.skip` and no findings entry. Recorded here for reviewer awareness.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Windows/PowerShell surfaces pnpm's Browserslist age notice on stderr as a non-terminating `NativeCommandError`; it does not affect the run. Focused runs and the full `pnpm test:ci` are green.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- The `data-state/src/enemy` top-level implementation is behavior-covered and green; later plans (06-03 `data-base/src/enemy`, 06-08 `CoreState` integration) can reuse these inline fixture shapes.
|
||||
- Open input for the user: confirm the flagged TEST-01 acceptance assumption (D5 above) and triage the `#06-01-N` findings; no core code was modified in this phase.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files verified present: the 6 `data-state/src/enemy/*.test.ts` files and `06-02-SUMMARY.md`.
|
||||
- Task commits verified in history: `5b76cf4`, `b36a4ee`, `7b95670`.
|
||||
- `git diff --name-only` from `plan_head_before` lists only the 6 new `*.test.ts` files; no production source changed.
|
||||
- Focused runs and full `pnpm test:ci` green (29 files, 223 passed, 3 documented pre-existing skips).
|
||||
|
||||
---
|
||||
*Phase: 06-unit-tests*
|
||||
*Completed: 2026-09-14*
|
||||
17
.planning/phases/06-unit-tests/06-COVERAGE-MAP.md
Normal file
17
.planning/phases/06-unit-tests/06-COVERAGE-MAP.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Phase 6: 单元测试 - 阶段级 code → 模块 → 用例 覆盖表
|
||||
|
||||
> 本文件为**共享 create-or-append**文件(幂等、顺序无关):各计划只追加/更新自己的小节,
|
||||
> 绝不重写他节。表头固定为 `| code | 模块 | 用例 | 计划 |`,因此任何 wave 1 计划的执行顺序
|
||||
> 都不影响最终结果。码表权威来源为 `packages/common/src/logger.json`。
|
||||
|
||||
| code | 模块 | 用例 | 计划 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
## 06-01 战斗系统(packages-user/data-system/src/combat)
|
||||
|
||||
模块归属:97 / 98 / 99 / 100 / 101 / 110 → `combat/context.ts`(`EnemyContext` 光环流水线);
|
||||
106 / 107 → `combat/damage.ts`(`DamageContext` / `DamageSystem`);
|
||||
102 / 103 / 104 → `combat/mapDamage.ts`(`MapDamage`);
|
||||
138 / 139 / 140 / 141 → `combat/combat.ts`(`CombatFlow`)。105 在当前实现不可达,排除。
|
||||
|
||||
阶段 1(构件级)不产生 warn/error 码断言。上表各 code 对应的具体用例名将在阶段 2/3 完成后补全。
|
||||
@ -1,11 +1,22 @@
|
||||
// 测试怪物上下文:注册与查询、未知查找、删除、resize、范围遍历与各类效果注册表增删
|
||||
// 测试怪物上下文:阶段 1(构件级)覆盖全部公开方法、单光环三范围、单效果与生命周期
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type IEnemy, type IStateBase } from '@user/data-base';
|
||||
import { type IRange } from '@motajs/common';
|
||||
import {
|
||||
type IEnemy,
|
||||
type IReadonlyEnemy,
|
||||
type IStateBase,
|
||||
type ISpecial
|
||||
} from '@user/data-base';
|
||||
import {
|
||||
type IAuraConverter,
|
||||
type IAuraView,
|
||||
type IEnemyCommonQueryEffect,
|
||||
type IEnemyFinalEffect,
|
||||
type IEnemySpecialQueryEffect
|
||||
type IEnemyHandler,
|
||||
type IEnemySpecialModifier,
|
||||
type IEnemySpecialQueryEffect,
|
||||
type IEnemyView,
|
||||
type IReadonlyHeroAttribute
|
||||
} from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
@ -56,6 +67,10 @@ interface TestModules {
|
||||
EnemyContext: typeof import('./context').EnemyContext;
|
||||
Enemy: typeof import('@user/data-base').Enemy;
|
||||
HeroAttribute: typeof import('@user/data-base').HeroAttribute;
|
||||
RectRange: typeof import('@motajs/common').RectRange;
|
||||
ManhattanRange: typeof import('@motajs/common').ManhattanRange;
|
||||
FullRange: typeof import('@motajs/common').FullRange;
|
||||
logger: typeof import('@motajs/common').logger;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
@ -65,10 +80,15 @@ beforeAll(async () => {
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const contextModule = await import('./context');
|
||||
const baseModule = await import('@user/data-base');
|
||||
const motaModule = await import('@motajs/common');
|
||||
modules = {
|
||||
EnemyContext: contextModule.EnemyContext,
|
||||
Enemy: baseModule.Enemy,
|
||||
HeroAttribute: baseModule.HeroAttribute
|
||||
HeroAttribute: baseModule.HeroAttribute,
|
||||
RectRange: motaModule.RectRange,
|
||||
ManhattanRange: motaModule.ManhattanRange,
|
||||
FullRange: motaModule.FullRange,
|
||||
logger: motaModule.logger
|
||||
};
|
||||
});
|
||||
|
||||
@ -83,7 +103,7 @@ interface FakeSpecial {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个最小特殊属性对象,只用于驱动注册表的匹配路径
|
||||
* 创建一个最小特殊属性对象,只用于驱动注册表与流水线的特殊属性路径
|
||||
* @param code 特殊属性代码
|
||||
*/
|
||||
function createSpecial(code: number): FakeSpecial {
|
||||
@ -91,15 +111,19 @@ function createSpecial(code: number): FakeSpecial {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个怪物对象
|
||||
* 创建一个最小怪物对象
|
||||
* @param id 怪物 id
|
||||
* @param code 怪物图块数字
|
||||
* @param attrs 需要覆盖的基础属性
|
||||
*/
|
||||
function createEnemy(id: string, code: number): IEnemy<TestEnemyAttr> {
|
||||
return new modules.Enemy<TestEnemyAttr>(id, code, {
|
||||
function createEnemy(
|
||||
id: string,
|
||||
attrs: Partial<TestEnemyAttr> = {}
|
||||
): IEnemy<TestEnemyAttr> {
|
||||
return new modules.Enemy<TestEnemyAttr>(id, 1, {
|
||||
hp: 10,
|
||||
atk: 2,
|
||||
def: 0
|
||||
def: 0,
|
||||
...attrs
|
||||
});
|
||||
}
|
||||
|
||||
@ -108,6 +132,8 @@ interface ContextFixture {
|
||||
context: InstanceType<TestModules['EnemyContext']>;
|
||||
/** 可修改勇士属性 */
|
||||
hero: InstanceType<TestModules['HeroAttribute']>;
|
||||
/** 数据层状态假对象 */
|
||||
state: IStateBase;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,25 +141,207 @@ interface ContextFixture {
|
||||
* @param width 上下文宽度
|
||||
* @param height 上下文高度
|
||||
*/
|
||||
function createContext(width: number = 4, height: number = 3): ContextFixture {
|
||||
function createContextFixture(
|
||||
width: number = 4,
|
||||
height: number = 3
|
||||
): ContextFixture {
|
||||
const state = {} as IStateBase;
|
||||
const context = new modules.EnemyContext<TestEnemyAttr, TestHeroAttr>(
|
||||
state
|
||||
);
|
||||
const context = new modules.EnemyContext<TestEnemyAttr, TestHeroAttr>(state);
|
||||
context.resize(width, height);
|
||||
const hero = new modules.HeroAttribute<TestHeroAttr>({
|
||||
hp: 100,
|
||||
atk: 0,
|
||||
def: 0
|
||||
});
|
||||
return { context, hero };
|
||||
return { context, hero, state };
|
||||
}
|
||||
|
||||
describe('EnemyContext registry', () => {
|
||||
type FakeApplyHandler = (
|
||||
handler: IEnemyHandler<TestEnemyAttr, TestHeroAttr>,
|
||||
base: IReadonlyEnemy<TestEnemyAttr>
|
||||
) => void;
|
||||
|
||||
type FakeApplySpecialHandler = (
|
||||
handler: IEnemyHandler<TestEnemyAttr, TestHeroAttr>,
|
||||
base: IReadonlyEnemy<TestEnemyAttr>
|
||||
) => IEnemySpecialModifier<TestEnemyAttr> | null;
|
||||
|
||||
interface FakeAuraOptions {
|
||||
/** 光环优先级 */
|
||||
readonly priority: number;
|
||||
/** 光环影响范围 */
|
||||
readonly range: IRange<any>;
|
||||
/** 范围扫描参数 */
|
||||
readonly param: any;
|
||||
/** 是否可以修改基础属性 */
|
||||
readonly couldApplyBase?: boolean;
|
||||
/** 是否可以修改特殊属性 */
|
||||
readonly couldApplySpecial?: boolean;
|
||||
/** 施加基础属性效果 */
|
||||
readonly onApply?: FakeApplyHandler;
|
||||
/** 产生特殊属性修饰器 */
|
||||
readonly onApplySpecial?: FakeApplySpecialHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用光环视图,只实现 IAuraView 表面,效果由外部回调决定
|
||||
*/
|
||||
class FakeAura implements IAuraView<TestEnemyAttr, any> {
|
||||
readonly priority: number;
|
||||
readonly range: IRange<any>;
|
||||
readonly couldApplyBase: boolean;
|
||||
readonly couldApplySpecial: boolean;
|
||||
private readonly param: any;
|
||||
private readonly applyHandler: FakeApplyHandler | null;
|
||||
private readonly applySpecialHandler: FakeApplySpecialHandler | null;
|
||||
|
||||
/**
|
||||
* @param options 光环配置
|
||||
*/
|
||||
constructor(options: FakeAuraOptions) {
|
||||
this.priority = options.priority;
|
||||
this.range = options.range;
|
||||
this.param = options.param;
|
||||
this.couldApplyBase = options.couldApplyBase ?? true;
|
||||
this.couldApplySpecial = options.couldApplySpecial ?? false;
|
||||
this.applyHandler = options.onApply ?? null;
|
||||
this.applySpecialHandler = options.onApplySpecial ?? null;
|
||||
}
|
||||
|
||||
getRangeParam(): any {
|
||||
return this.param;
|
||||
}
|
||||
|
||||
apply(
|
||||
handler: IEnemyHandler<TestEnemyAttr, TestHeroAttr>,
|
||||
base: IReadonlyEnemy<TestEnemyAttr>
|
||||
): void {
|
||||
this.applyHandler?.(handler, base);
|
||||
}
|
||||
|
||||
applySpecial(
|
||||
handler: IEnemyHandler<TestEnemyAttr, TestHeroAttr>,
|
||||
base: IReadonlyEnemy<TestEnemyAttr>
|
||||
): IEnemySpecialModifier<TestEnemyAttr> | null {
|
||||
return this.applySpecialHandler?.(handler, base) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
type FakeAuraFactory = (code: number) => IAuraView<TestEnemyAttr> | null;
|
||||
|
||||
/**
|
||||
* 测试用光环转换器,命中指定代码集合时按工厂函数产出光环
|
||||
*/
|
||||
class FakeConverter implements IAuraConverter<TestEnemyAttr, TestHeroAttr> {
|
||||
/** 会被此转换器命中的特殊属性代码 */
|
||||
readonly codes: number[];
|
||||
/** shouldConvert 的调用记录 */
|
||||
readonly shouldConvertCalls: number[] = [];
|
||||
/** convert 的调用记录 */
|
||||
readonly convertCalls: number[] = [];
|
||||
private readonly factory: FakeAuraFactory | null;
|
||||
|
||||
/**
|
||||
* @param codes 会被命中的特殊属性代码
|
||||
* @param factory 光环工厂,缺省表示转换被跳过
|
||||
*/
|
||||
constructor(codes: number[], factory: FakeAuraFactory | null = null) {
|
||||
this.codes = codes;
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
shouldConvert(special: ISpecial<any>): boolean {
|
||||
this.shouldConvertCalls.push(special.code);
|
||||
return this.codes.includes(special.code);
|
||||
}
|
||||
|
||||
convert(special: ISpecial<any>): IAuraView<TestEnemyAttr> | null {
|
||||
this.convertCalls.push(special.code);
|
||||
return this.factory ? this.factory(special.code) : null;
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeDamageSystemCalls {
|
||||
/** 最近一次绑定的勇士属性 */
|
||||
hero: IReadonlyHeroAttribute<TestHeroAttr> | null;
|
||||
/** markDirty 收到的怪物视图 */
|
||||
marked: IEnemyView<TestEnemyAttr>[];
|
||||
/** deleteEnemy 收到的怪物视图 */
|
||||
deleted: IEnemyView<TestEnemyAttr>[];
|
||||
/** markAllDirty 调用次数 */
|
||||
markAllDirtyCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只记录调用的伤害系统假对象
|
||||
*/
|
||||
function createFakeDamageSystem(): {
|
||||
system: never;
|
||||
calls: FakeDamageSystemCalls;
|
||||
} {
|
||||
const calls: FakeDamageSystemCalls = {
|
||||
hero: null,
|
||||
marked: [],
|
||||
deleted: [],
|
||||
markAllDirtyCount: 0
|
||||
};
|
||||
const system = {
|
||||
bindHeroStatus: (hero: IReadonlyHeroAttribute<TestHeroAttr> | null) => {
|
||||
calls.hero = hero;
|
||||
},
|
||||
markDirty: (view: IEnemyView<TestEnemyAttr>) => {
|
||||
calls.marked.push(view);
|
||||
},
|
||||
deleteEnemy: (view: IEnemyView<TestEnemyAttr>) => {
|
||||
calls.deleted.push(view);
|
||||
},
|
||||
markAllDirty: () => {
|
||||
calls.markAllDirtyCount++;
|
||||
}
|
||||
};
|
||||
return { system: system as never, calls };
|
||||
}
|
||||
|
||||
interface FakeMapDamageCalls {
|
||||
/** refreshAll 调用次数 */
|
||||
refreshAllCount: number;
|
||||
/** markEnemyDirty 收到的怪物视图 */
|
||||
marked: IEnemyView<TestEnemyAttr>[];
|
||||
/** deleteEnemy 收到的怪物视图 */
|
||||
deleted: IEnemyView<TestEnemyAttr>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只记录调用的地图伤害假对象
|
||||
*/
|
||||
function createFakeMapDamage(): {
|
||||
damage: never;
|
||||
calls: FakeMapDamageCalls;
|
||||
} {
|
||||
const calls: FakeMapDamageCalls = {
|
||||
refreshAllCount: 0,
|
||||
marked: [],
|
||||
deleted: []
|
||||
};
|
||||
const damage = {
|
||||
refreshAll: () => {
|
||||
calls.refreshAllCount++;
|
||||
},
|
||||
markEnemyDirty: (view: IEnemyView<TestEnemyAttr>) => {
|
||||
calls.marked.push(view);
|
||||
},
|
||||
deleteEnemy: (view: IEnemyView<TestEnemyAttr>) => {
|
||||
calls.deleted.push(view);
|
||||
}
|
||||
};
|
||||
return { damage: damage as never, calls };
|
||||
}
|
||||
|
||||
describe('EnemyContext registry and lookups', () => {
|
||||
// 验证注册怪物后可按定位符、坐标与计算后怪物反查
|
||||
it('registers an enemy and resolves it from locator, point and computed', () => {
|
||||
const fixture = createContext();
|
||||
const enemy = createEnemy('e1', 1);
|
||||
const fixture = createContextFixture();
|
||||
const enemy = createEnemy('e1');
|
||||
|
||||
fixture.context.setEnemyAt({ x: 1, y: 0 }, enemy);
|
||||
const view = fixture.context.getEnemyByLocator({ x: 1, y: 0 });
|
||||
@ -152,8 +360,8 @@ describe('EnemyContext registry', () => {
|
||||
|
||||
// 验证未知定位符、未知坐标与未知怪物对象均返回 null
|
||||
it('returns null for unknown lookups', () => {
|
||||
const fixture = createContext();
|
||||
const other = createEnemy('other', 2);
|
||||
const fixture = createContextFixture();
|
||||
const other = createEnemy('other');
|
||||
|
||||
expect(fixture.context.getEnemyByLocator({ x: 3, y: 2 })).toBeNull();
|
||||
expect(fixture.context.getEnemyByLoc(3, 2)).toBeNull();
|
||||
@ -161,11 +369,11 @@ describe('EnemyContext registry', () => {
|
||||
expect(fixture.context.getEnemyLocator(other)).toBeNull();
|
||||
});
|
||||
|
||||
// 验证同一坐标重复注册会替换原有怪物及其映射
|
||||
// 验证同一坐标重复注册会替换原有怪物及其全部映射
|
||||
it('replaces the enemy registered at the same point', () => {
|
||||
const fixture = createContext();
|
||||
const first = createEnemy('first', 1);
|
||||
const second = createEnemy('second', 2);
|
||||
const fixture = createContextFixture();
|
||||
const first = createEnemy('first');
|
||||
const second = createEnemy('second');
|
||||
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, first);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, second);
|
||||
@ -176,8 +384,8 @@ describe('EnemyContext registry', () => {
|
||||
|
||||
// 验证删除怪物会同时移除视图与定位符映射
|
||||
it('removes view and locator mappings when deleting an enemy', () => {
|
||||
const fixture = createContext();
|
||||
const enemy = createEnemy('e1', 1);
|
||||
const fixture = createContextFixture();
|
||||
const enemy = createEnemy('e1');
|
||||
fixture.context.setEnemyAt({ x: 1, y: 0 }, enemy);
|
||||
const view = fixture.context.getEnemyByLocator({ x: 1, y: 0 })!;
|
||||
const computed = view.getComputedEnemy();
|
||||
@ -192,8 +400,8 @@ describe('EnemyContext registry', () => {
|
||||
|
||||
// 验证 resize 会清空怪物并更新尺寸与索引宽度
|
||||
it('resizes by clearing enemies and updating dimensions', () => {
|
||||
const fixture = createContext(4, 3);
|
||||
fixture.context.setEnemyAt({ x: 1, y: 0 }, createEnemy('e1', 1));
|
||||
const fixture = createContextFixture(4, 3);
|
||||
fixture.context.setEnemyAt({ x: 1, y: 0 }, createEnemy('e1'));
|
||||
|
||||
fixture.context.resize(5, 2);
|
||||
|
||||
@ -203,20 +411,14 @@ describe('EnemyContext registry', () => {
|
||||
expect(fixture.context.getEnemyByLocator({ x: 1, y: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
// 验证 scanRange 与 iterateEnemy 会遍历全部已注册怪物
|
||||
it('scans and iterates all registered enemies', () => {
|
||||
const fixture = createContext();
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('first', 1));
|
||||
fixture.context.setEnemyAt({ x: 2, y: 1 }, createEnemy('second', 2));
|
||||
const range = {
|
||||
bindHost: () => {},
|
||||
autoDetect: (list: Set<number>) => [...list]
|
||||
};
|
||||
// 验证 iterateEnemy 会遍历全部已注册怪物及其定位符
|
||||
it('iterates all registered enemies', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('first'));
|
||||
fixture.context.setEnemyAt({ x: 2, y: 1 }, createEnemy('second'));
|
||||
|
||||
const scanned = [...fixture.context.scanRange(range as never, 0)];
|
||||
const iterated = [...fixture.context.iterateEnemy()];
|
||||
|
||||
expect(scanned).toHaveLength(2);
|
||||
expect(iterated).toHaveLength(2);
|
||||
expect(iterated.map(([locator]) => locator)).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
@ -224,9 +426,27 @@ describe('EnemyContext registry', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证 scanRange 使用真实矩形范围时只返回范围内的怪物
|
||||
it('scans enemies inside the given range only', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('inside'));
|
||||
fixture.context.setEnemyAt({ x: 3, y: 2 }, createEnemy('outside'));
|
||||
|
||||
const scanned = [
|
||||
...fixture.context.scanRange(new modules.RectRange(), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 2,
|
||||
h: 1
|
||||
})
|
||||
];
|
||||
|
||||
expect(scanned.map(([locator]) => locator)).toEqual([{ x: 0, y: 0 }]);
|
||||
});
|
||||
|
||||
// 验证绑定与解绑勇士对象会同步更新查询结果
|
||||
it('binds and clears the bound hero', () => {
|
||||
const fixture = createContext();
|
||||
const fixture = createContextFixture();
|
||||
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
expect(fixture.context.getBindedHero()).toBe(fixture.hero);
|
||||
@ -236,34 +456,49 @@ describe('EnemyContext registry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnemyContext effect registries', () => {
|
||||
// 验证注册的最终效果会在构建时执行,注销后不再执行
|
||||
it('runs registered final effects until they are unregistered', () => {
|
||||
const fixture = createContext();
|
||||
describe('EnemyContext converters and effect registrations', () => {
|
||||
// 验证注册的光环转换器会参与特殊属性转换,注销后不再参与
|
||||
it('consults aura converters until they are unregistered', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1', 1));
|
||||
const applied: number[] = [];
|
||||
const effect: IEnemyFinalEffect<TestEnemyAttr, TestHeroAttr> = {
|
||||
priority: 1,
|
||||
apply: () => {
|
||||
applied.push(1);
|
||||
}
|
||||
};
|
||||
const enemy = createEnemy('e1');
|
||||
enemy.addSpecial(createSpecial(20) as never);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, enemy);
|
||||
const converter = new FakeConverter([20]);
|
||||
|
||||
fixture.context.registerFinalEffect(effect);
|
||||
fixture.context.registerAuraConverter(converter);
|
||||
fixture.context.buildup();
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(converter.shouldConvertCalls).toHaveLength(1);
|
||||
|
||||
fixture.context.unregisterFinalEffect(effect);
|
||||
fixture.context.unregisterAuraConverter(converter);
|
||||
fixture.context.buildup();
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(converter.shouldConvertCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
// 验证禁用光环转换器后不再参与转换,重新启用后恢复参与
|
||||
it('skips a converter while it is disabled', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
const enemy = createEnemy('e1');
|
||||
enemy.addSpecial(createSpecial(20) as never);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, enemy);
|
||||
const converter = new FakeConverter([20]);
|
||||
|
||||
fixture.context.registerAuraConverter(converter);
|
||||
fixture.context.setAuraConverterEnabled(converter, false);
|
||||
fixture.context.buildup();
|
||||
expect(converter.shouldConvertCalls).toHaveLength(0);
|
||||
|
||||
fixture.context.setAuraConverterEnabled(converter, true);
|
||||
fixture.context.buildup();
|
||||
expect(converter.shouldConvertCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
// 验证注册的常规查询效果会按特殊属性代码执行,注销后不再执行
|
||||
it('runs common query effects for matching specials until unregistered', () => {
|
||||
const fixture = createContext();
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
const enemy = createEnemy('e1', 1);
|
||||
const enemy = createEnemy('e1');
|
||||
enemy.addSpecial(createSpecial(20) as never);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, enemy);
|
||||
const queried: number[] = [];
|
||||
@ -285,9 +520,9 @@ describe('EnemyContext effect registries', () => {
|
||||
|
||||
// 验证注册的特殊查询效果会在构建时构造修饰器,注销后不再构造
|
||||
it('builds special query modifiers until unregistered', () => {
|
||||
const fixture = createContext();
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1', 1));
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1'));
|
||||
const forCalls: number[] = [];
|
||||
const modifier = {
|
||||
add: () => [],
|
||||
@ -312,28 +547,300 @@ describe('EnemyContext effect registries', () => {
|
||||
expect(forCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
// 验证注册的光环转换器会参与特殊属性转换,注销后不再参与
|
||||
it('consults aura converters until they are unregistered', () => {
|
||||
const fixture = createContext();
|
||||
// 验证注册的最终效果会在构建时执行,注销后不再执行
|
||||
it('runs registered final effects until they are unregistered', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
const enemy = createEnemy('e1', 1);
|
||||
enemy.addSpecial(createSpecial(20) as never);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, enemy);
|
||||
const consulted: number[] = [];
|
||||
const converter: IAuraConverter<TestEnemyAttr, TestHeroAttr> = {
|
||||
shouldConvert: () => {
|
||||
consulted.push(1);
|
||||
return false;
|
||||
},
|
||||
convert: () => null as never
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1'));
|
||||
const applied: number[] = [];
|
||||
const effect: IEnemyFinalEffect<TestEnemyAttr, TestHeroAttr> = {
|
||||
priority: 1,
|
||||
apply: () => {
|
||||
applied.push(1);
|
||||
}
|
||||
};
|
||||
|
||||
fixture.context.registerAuraConverter(converter);
|
||||
fixture.context.registerFinalEffect(effect);
|
||||
fixture.context.buildup();
|
||||
expect(consulted).toHaveLength(1);
|
||||
expect(applied).toHaveLength(1);
|
||||
|
||||
fixture.context.unregisterAuraConverter(converter);
|
||||
fixture.context.unregisterFinalEffect(effect);
|
||||
fixture.context.buildup();
|
||||
expect(consulted).toHaveLength(1);
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnemyContext single aura ranges', () => {
|
||||
// 验证 Full 范围(覆盖全图)的光环会加成范围内全部怪物
|
||||
it('applies a full-range aura to every enemy', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.registerAuraConverter(new FakeConverter([]));
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('first'));
|
||||
fixture.context.setEnemyAt({ x: 3, y: 2 }, createEnemy('second'));
|
||||
fixture.context.addAura(
|
||||
new FakeAura({
|
||||
priority: 1,
|
||||
range: new modules.FullRange(),
|
||||
param: undefined,
|
||||
onApply: handler => handler.enemy.addAttribute('atk', 3)
|
||||
})
|
||||
);
|
||||
|
||||
fixture.context.buildup();
|
||||
|
||||
expect(
|
||||
fixture.context
|
||||
.getEnemyByLoc(0, 0)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(5);
|
||||
expect(
|
||||
fixture.context
|
||||
.getEnemyByLoc(3, 2)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
// 验证 Rect 范围的光环只加成矩形内怪物,范围外怪物属性不变
|
||||
it('applies a rect-range aura inside the rectangle only', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.registerAuraConverter(new FakeConverter([]));
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('inside'));
|
||||
fixture.context.setEnemyAt({ x: 2, y: 0 }, createEnemy('outside'));
|
||||
fixture.context.addAura(
|
||||
new FakeAura({
|
||||
priority: 1,
|
||||
range: new modules.RectRange(),
|
||||
param: { x: 0, y: 0, w: 2, h: 1 },
|
||||
onApply: handler => handler.enemy.addAttribute('atk', 5)
|
||||
})
|
||||
);
|
||||
|
||||
fixture.context.buildup();
|
||||
|
||||
expect(
|
||||
fixture.context
|
||||
.getEnemyByLoc(0, 0)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(7);
|
||||
expect(
|
||||
fixture.context
|
||||
.getEnemyByLoc(2, 0)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
// 验证 Manhattan 范围的光环只加成曼哈顿距离内怪物,范围外怪物属性不变
|
||||
it('applies a manhattan-range aura inside the radius only', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.registerAuraConverter(new FakeConverter([]));
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('inside'));
|
||||
fixture.context.setEnemyAt({ x: 2, y: 2 }, createEnemy('outside'));
|
||||
fixture.context.addAura(
|
||||
new FakeAura({
|
||||
priority: 1,
|
||||
range: new modules.ManhattanRange(),
|
||||
param: { cx: 0, cy: 0, radius: 1 },
|
||||
onApply: handler => handler.enemy.addAttribute('atk', 5)
|
||||
})
|
||||
);
|
||||
|
||||
fixture.context.buildup();
|
||||
|
||||
expect(
|
||||
fixture.context
|
||||
.getEnemyByLoc(0, 0)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(7);
|
||||
expect(
|
||||
fixture.context
|
||||
.getEnemyByLoc(2, 2)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
// 验证单个光环的特殊属性效果只施加一次,并把特殊属性加到计算中怪物身上
|
||||
it('applies a single aura special effect once', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.registerAuraConverter(new FakeConverter([]));
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1'));
|
||||
fixture.context.addAura(
|
||||
new FakeAura({
|
||||
priority: 1,
|
||||
range: new modules.FullRange(),
|
||||
param: undefined,
|
||||
couldApplyBase: false,
|
||||
couldApplySpecial: true,
|
||||
onApplySpecial: () => ({
|
||||
add: () => [createSpecial(30) as never],
|
||||
delete: () => [],
|
||||
modify: () => false
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
fixture.context.buildup();
|
||||
|
||||
expect(
|
||||
fixture.context.getEnemyByLoc(0, 0)!.getComputedEnemy().hasSpecial(30)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnemyContext attachments and dirty marking', () => {
|
||||
// 验证绑定地图伤害后会立即刷新,并能通过 getMapDamage 取回
|
||||
it('attaches map damage and refreshes it', () => {
|
||||
const fixture = createContextFixture();
|
||||
const fake = createFakeMapDamage();
|
||||
|
||||
fixture.context.attachMapDamage(fake.damage);
|
||||
expect(fixture.context.getMapDamage()).toBe(fake.damage);
|
||||
expect(fake.calls.refreshAllCount).toBe(1);
|
||||
|
||||
fixture.context.attachMapDamage(null);
|
||||
expect(fixture.context.getMapDamage()).toBeNull();
|
||||
});
|
||||
|
||||
// 验证绑定伤害系统会同步勇士状态,并能通过 getDamageSystem 取回
|
||||
it('attaches a damage system and syncs the hero status', () => {
|
||||
const fixture = createContextFixture();
|
||||
const fake = createFakeDamageSystem();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
|
||||
fixture.context.attachDamageSystem(fake.system);
|
||||
expect(fixture.context.getDamageSystem()).toBe(fake.system);
|
||||
expect(fake.calls.hero).toBe(fixture.hero);
|
||||
|
||||
fixture.context.attachDamageSystem(null);
|
||||
expect(fixture.context.getDamageSystem()).toBeNull();
|
||||
});
|
||||
|
||||
// 验证绑定勇士后会把勇士状态同步给已附加的伤害系统并刷新地图伤害
|
||||
it('propagates the hero binding to attached collaborators', () => {
|
||||
const fixture = createContextFixture();
|
||||
const fakeDamage = createFakeDamageSystem();
|
||||
const fakeMapDamage = createFakeMapDamage();
|
||||
fixture.context.attachDamageSystem(fakeDamage.system);
|
||||
fixture.context.attachMapDamage(fakeMapDamage.damage);
|
||||
const refreshBefore = fakeMapDamage.calls.refreshAllCount;
|
||||
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
|
||||
expect(fakeDamage.calls.hero).toBe(fixture.hero);
|
||||
expect(fakeMapDamage.calls.refreshAllCount).toBe(refreshBefore + 1);
|
||||
});
|
||||
|
||||
// 验证 markDirty 只对已注册怪物生效并同步标记伤害系统
|
||||
it('marks a registered enemy dirty only', () => {
|
||||
const fixture = createContextFixture();
|
||||
const fake = createFakeDamageSystem();
|
||||
fixture.context.attachDamageSystem(fake.system);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1'));
|
||||
const view = fixture.context.getEnemyByLoc(0, 0)!;
|
||||
fake.calls.marked.length = 0;
|
||||
const unknown: IEnemyView<TestEnemyAttr> = {
|
||||
context: {} as never,
|
||||
reset: () => {},
|
||||
getBaseEnemy: () => createEnemy('unknown'),
|
||||
getComputedEnemy: () => createEnemy('unknown'),
|
||||
getModifiableEnemy: () => createEnemy('unknown'),
|
||||
markDirty: () => {}
|
||||
};
|
||||
|
||||
fixture.context.markDirty(view);
|
||||
fixture.context.markDirty(unknown);
|
||||
|
||||
expect(fake.calls.marked).toEqual([view]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnemyContext lifecycle', () => {
|
||||
// 验证 clear 会清空怪物映射与全局光环集合
|
||||
it('clears enemies and global auras', () => {
|
||||
const fixture = createContextFixture();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.registerAuraConverter(new FakeConverter([]));
|
||||
fixture.context.addAura(
|
||||
new FakeAura({
|
||||
priority: 1,
|
||||
range: new modules.FullRange(),
|
||||
param: undefined,
|
||||
onApply: handler => handler.enemy.addAttribute('atk', 9)
|
||||
})
|
||||
);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1'));
|
||||
fixture.context.buildup();
|
||||
|
||||
expect(
|
||||
fixture.context
|
||||
.getEnemyByLoc(0, 0)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(11);
|
||||
|
||||
fixture.context.clear();
|
||||
|
||||
expect([...fixture.context.iterateEnemy()]).toHaveLength(0);
|
||||
expect(fixture.context.getEnemyByLocator({ x: 0, y: 0 })).toBeNull();
|
||||
|
||||
fixture.context.setEnemyAt({ x: 1, y: 1 }, createEnemy('e2'));
|
||||
fixture.context.buildup();
|
||||
expect(
|
||||
fixture.context
|
||||
.getEnemyByLoc(1, 1)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
// 验证 destroy 会解绑附件、清空注册表与勇士绑定
|
||||
it('destroys attachments, registrations and the hero binding', () => {
|
||||
const fixture = createContextFixture();
|
||||
const fakeDamage = createFakeDamageSystem();
|
||||
const fakeMapDamage = createFakeMapDamage();
|
||||
fixture.context.attachDamageSystem(fakeDamage.system);
|
||||
fixture.context.attachMapDamage(fakeMapDamage.damage);
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.registerAuraConverter(new FakeConverter([20]));
|
||||
fixture.context.registerCommonQueryEffect(20, {
|
||||
priority: 1,
|
||||
apply: () => {}
|
||||
});
|
||||
fixture.context.registerSpecialQueryEffect({
|
||||
priority: 1,
|
||||
for: () => ({
|
||||
add: () => [],
|
||||
delete: () => [],
|
||||
modify: () => false,
|
||||
shouldQuery: () => false
|
||||
})
|
||||
});
|
||||
const applied: number[] = [];
|
||||
fixture.context.registerFinalEffect({
|
||||
priority: 1,
|
||||
apply: () => {
|
||||
applied.push(1);
|
||||
}
|
||||
});
|
||||
|
||||
fixture.context.destroy();
|
||||
|
||||
expect(fixture.context.getMapDamage()).toBeNull();
|
||||
expect(fixture.context.getDamageSystem()).toBeNull();
|
||||
expect(fixture.context.getBindedHero()).toBeNull();
|
||||
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1'));
|
||||
fixture.context.buildup();
|
||||
expect(applied).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user