Commit Graph
16 Commits
Author SHA1 Message Date
david raistrick b12ac904a6 test: add storage coverage (firebase contract, factory), silence scenario log
- firebase.contract.test.js: run storage contract against createFirebaseStorage
  via SDK mock. Currently 12 fail (firebase diverges from contract: no norm(),
  injects id). Failures real — firebase copied from main, memory/ws invented
  new requirements. Contract under review vs main prod truth.
- storage.factory.test.js: getStorage() routing per REACT_APP_STORAGE env,
  singleton cache, getStorageMode() reporting.
- Combat.scenario.test.js: remove console.log (test noise).
- package.json: test:all now runs App + shared + server suites (was missing App).
2026-07-03 18:53:58 -04:00
david raistrick 7f60ec140e fix(scenario): run 100 rounds not turns, exercise all combat paths
Previous test lied: called nextTurn 100x = ~100 turns = ~12 rounds (see
docs/GLOSSARY.md turn vs round). Now loops by actual round-wrap count.

100 rounds = 975 turns verified via console log + expect(roundsDone).toBe(100).

Exercises ALL shared combat paths deterministically (vs replay random):
startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange
(damage+heal), deathSave (1/2/3-success → isDying path), toggleCondition,
reorderParticipants (drag same-init tie), endEncounter, activateDisplay,
clearDisplay, toggleHidePlayerHp.

Rotation integrity checked every 10 rounds: no dup turnOrderIds, currentTurn
in order, turnOrderIds === participants.map(id).

Run time: 19ms (was 72s React, lieing).
2026-07-03 18:29:35 -04:00
david raistrick e650cd2710 refactor: single-source display lifecycle + scenario test no React
Move activeDisplay lifecycle to shared (one path, not duplicated in App):
- activateDisplay({campaignId, encounterId})
- clearDisplay()
- toggleHidePlayerHp(current)
All 6 App sites wired (start/end/2×delete-encounter/delete-campaign/hp-toggle).
Existing {patch,log} return shape preserved. No interface changes.

Combat.scenario.test.js rewritten to call shared directly, no React:
- Same actions as old UI version (roster chars, monsters, addAll, hp-toggle×2,
  start, 100 rounds of damage/heal/conditions/toggle/edit/deathsave/pause/
  resume/add/remove, end). Same funcs, same order, same data.
- Models two firestore docs (encounter + activeDisplay) via shared patches.
- Run time: 72s -> 4ms.

Both suites green: shared 91/91, App 77/77.
2026-07-03 18:26:02 -04:00
david raistrick d1cc3a8b9a test: warning=failure guard + fix ambiguous switch selector
- setupTests.js: console.error/warn now throw. Warning = failure.
- Combat.characterization: two role=switch (player HP + NPC HP), scope
  to player-HP label. getByRole('switch') was ambiguous.

Full suite: 77/77 pass, 0 warnings.
2026-07-03 16:05:04 -04:00
david raistrick 31d19c5912 test: kill act env warnings + remove debug console noise
Root cause: mock _notify cb fires during waitFor poll, but testing-library
asyncWrapper flips IS_REACT_ACT_ENVIRONMENT=false. Raw react act() then
warns 'not configured to support act' (146 warnings).

Fix: _notify sets globalThis.IS_REACT_ACT_ENVIRONMENT=true around act(),
restores after. setupTests sets flag globally. Both together = 0 warnings.

Also:
- Remove 4 debug console.log from App.js (encounter start/end, drag, add chars)
- Remove always-fires scenario summary console.log
- Add fastWaitFor helper (5ms poll) to testHelpers
- Swap scenario waitFor -> fastWaitFor

Tests: 77 pass / 0 fail / 0 warnings / 0 console noise.
2026-07-03 15:59:14 -04:00
david raistrick 3d0dc79206 feat(UI): hide NPC/monster HP toggle (player display)
Mirrors hidePlayerHp. New hideNpcHp flag on activeDisplay doc.
- AdminView: 'Hide NPC/monster HP' switch in Player Display settings.
- DisplayView: HP bar gated by !(hideNpcHp && p.type !== 'character').
  Covers monsters + NPCs (all non-player).
- Default false (show NPC HP) — opposite of player HP default true.

updateDoc patch (BUG-4 pattern). No clobber.

Test: HideHpToggle selector scoped to 'hide player hp' (now 2 switches).
2026-07-02 11:29:02 -04:00
david raistrick c0998da0a7 fix(BUG-4): updateDoc patch on activeDisplay (not setDoc replace)
All 5 storage.setDoc(activeDisplay, {...}, {merge:true}) →
storage.updateDoc(activeDisplay, {...}).

setDoc merge:true worked in prod (firebase honors merge) but ws adapter
+ mock ignore opts arg entirely → clobbers doc. updateDoc uses PATCH
across all adapters (firebase real updateDoc, ws PATCH endpoint, mock
merge). Consistent, no clobber.

Sites fixed:
- hidePlayerHp toggle
- startEncounter (set active ids)
- endEncounter (null active ids)
- deactivate active display
- activate new display

TDD: HideHpToggle.test RED first (assert updateDoc patch, impl still
setDoc → 0 calls found). GREEN after switch.

Char tests updated: Encounter.characterization (2) + Combat.characterization
(2) assert updateDoc on activeDisplay, not setDoc.

BUG-4: prod was already fixed (merge:true), test was RED due to mock
ignoring opts. Now all 3 adapters consistent via updateDoc.
2026-07-01 23:32:23 -04:00
david raistrick d00cc104c9 feat(FEAT-3): reslot on all participant mutation paths
Reslot (stable sort by init desc, tie-break = original array index) now
fires on all 4 paths that can change order:

1. Add participant — sortParticipantsByInitiative([...parts, new], parts)
2. Edit modal save (handleUpdateParticipant) — reslot + syncTurnOrder
3. Drag reorder — splice move (already correct, untouched)
4. Inline init field — reslot (already committed 08c27c1)

Before: add appended (ignored init), edit modal overwrote value without
moving slot. Both caused list order to drift from init order until
startEncounter (sorts once). Now any init change immediately reslots
into correct position. Display + AdminView reflect order.

Stable sort preserves drag order within ties (tie-break = original index
= reflects prior drag). Move-one semantics: only changed element moves.

EditParticipantModal: added htmlFor/id link on Initiative label (was
missing — a11y + testable).

Tests: ReslotAllPaths.test.js (2). RED first (add appended, edit modal
no reslot), green after impl.
2026-07-01 23:00:40 -04:00
david raistrick 08c27c1ca5 feat(FEAT-3): reslot on inline init change + gate field
Reslot: handleInlineInitiative now sorts participants[] by init desc
(stable, tie-break original index) via sortParticipantsByInitiative.
Display + AdminView reflect new order after init edit. Not a blind
re-sort — only moved element changes position.

Gate: inline init field disabled when combat active + not paused.
Matches drag gating. DM must pause to edit initiative mid-combat.

Tests: InitiativeReslot.test.js (2). RED first (no reslot, Goblin stayed
at idx 1), green after impl (reslots to idx 0). Field gate test.
2026-07-01 22:29:38 -04:00
david raistrick 0514939c51 feat(FEAT-3): initiative first-class field (add + inline edit)
Add form: optional initiative field (monster + character). Empty = roll
d20+mod (current behavior). Filled = use value, skip roll. 'blank=roll'
hint + 'auto' placeholder for clarity.

Inline edit: ALL participants. Number input in participant row. Blur or
Enter commits. Capped 2 digits (max 99). Auto-select on focus for quick
overwrite. Styled to match other fields (border-stone-700, rounded-md,
shadow-sm, w-10).

handleAddParticipant: manualInit detects set value. lastRollDetails
adapts display (manual flag shows 'Set initiative' vs 'Rolled d20').

Campaign card date: Clock icon + 'Created:' prefix, own row, muted
stone-300 opacity-70. Was crammed inline with character/encounter counts.

Tests: InitiativeField.test.js (2 tests - set value + empty=roll).
RED first (field missing), then green after impl. App + Participant
characterization still green (18 total).

Note: inline init edit does NOT re-sort. Known followup — displays + list
order must reflect changed initiative. Tracked separately.
2026-07-01 22:25:52 -04:00
david raistrick da25f46e3e fix(docker): single container (caddy+node), ESM adapters fix blank page
Docker: moved all docker files to docker/ tree (was conflated with
upstream Dockerfile at root + server/Dockerfile). Single container now:
caddy (front, serves static + proxies /api /ws) + node backend (internal
:4001). Node never exposed. entrypoint.sh runs both. Compose: one service.

Blank page root cause: storage adapters had inconsistent module systems.
firebase.js = ESM (export). ws.js + memory.js = CJS (module.exports).
CRA prod build = ESM strict -> CJS runtime crash, blank root. Dev mode
lenient, masked bug. First ws prod build (docker) = first exposure.
Never dev/prod split intended; just inconsistency from M2 era.

Fix: all adapters ESM. ws.js lazy-loads 'ws' pkg via dynamic import()
(Node/jest only; browser uses global WebSocket). index.js static
imports. server jest: added babel.config.js (preset-env, node target)
to transform ESM for jest.

Test: src/tests/StorageEsm.test.js — 4 tests grep all adapters for
module.exports / require(). Regression guard catches CJS leak.

Verified: docker page renders (root 4534 chars, UI visible).
server 24 green, shared 90 green, FE ESM 4 green.
2026-07-01 19:03:59 -04:00
david raistrick 58ae04b400 fix(BUG-15): DisplayView no longer re-sorts participants by initiative
DisplayView called sortParticipantsByInitiative() on visibleParticipants,
ignoring DM drag order. 1-list model = participants[] IS display source.
After cross-init drag, player view diverged from AdminView/turnOrderIds.

Repro: round 4 replay. [reorder Summon1(10)→before Merchant(11)] made
turnOrderIds = [...,Summon2,Summon1,Merchant,OrcBoss]. AdminView correct.
DisplayView re-sorted = Summon2,Merchant,Summon1 (init order) = visually
Merchant appeared between Summon2 and Summon1, NOT at end. DM confused.

Fix: removed sort. DisplayView now renders participants[] order directly
(filter inactive monsters only), matching AdminView line 1222.

Test: RED → GREEN (src/tests/DisplayView.drag-order.test.js). Seeds 3
monsters in drag order [High:20, Low:10, Mid:11]. Asserts DOM order =
participants[] order, not init-sorted. No DisplayView regressions.
2026-07-01 17:31:40 -04:00
david raistrick dbd0c75792 fix(BUG-12): campaign selection follows activeDisplay
Selection effect had `!selectedCampaignId` guard — once any campaign
selected, new activeDisplay.activeCampaignId writes ignored. Replay tool
writes activeDisplay to new campaign each run; UI stayed on old selection
=> displayed wrong campaign data.

Removed guard. Selection now syncs when activeCampaignId differs from
current selection. Manual deselect (null) does not force-select (RED test
locks this).

Also fixed test helper bug: createCampaignViaUI/createEncounterViaUI
returned FIRST setDoc match (idA for all creates). Now filters by name +
.pop() for latest. This masked the real bug for several debug cycles.

Tests: 2 new (SelectionFollowsActiveDisplay), both green. No regressions
in full FE suite (App, Combat, DisplayView, Encounter, HideHpToggle,
Logs, Participant, storage all pass). Combat.scenario = pre-existing
BUG-11 crash, not regression.
2026-07-01 17:05:00 -04:00
david raistrick b024fa08bb tests: scenario rotation integrity check (every 10 rounds)
Combat.scenario.test.js: per-10-round assertion - turnOrderIds no dup,
currentTurnParticipantId in turnOrderIds. 299/299 phases pass.

NOTE: scenario runs against firebase MOCK. Mock updateDoc merges correctly
(real ws adapter would clobber per BUG-4 class). So check validates mock
shape, not adapter translation. Layer 2 (ws-contract) covers adapter.
2026-06-30 14:04:27 -04:00
david raistrick d979b03f2e tests: BUG-4 RED locked (hide-HP clobbers activeDisplay), add write recorder
src/tests/HideHpToggle.test.js: renders App, selects campaign, toggles
hide-player-HP switch, asserts setDoc data includes activeCampaignId +
activeEncounterId. RED: data only {hidePlayerHp:true}, both clobbered.

Root cause proven with evidence (recorder):
  setDoc(activeDisplay/status, {hidePlayerHp:true}, {merge:true})
  data written = {hidePlayerHp:true} ONLY
  activeCampaignId = undefined
  activeEncounterId = undefined

setDoc = replace per contract. {merge:true} arg ignored. Toggle wipes
encounter pointer → DisplayView reads null → 'Game Session Paused'.
Fix: use updateDoc (patch), not setDoc.

src/storage/firebase.js: adapter recorder now captures setDoc + updateDoc
(data + opts). Was subscribe-only. Enables write-path assertions.
2026-06-30 13:55:14 -04:00
david raistrick f81308a0df tests: consolidate into tests/ dirs, fix import paths
Move all test files out of source dirs into per-workspace tests/:
- shared/tests/   (3 unit test files)
- server/tests/   (1 integration test)
- src/tests/      (8 characterization + scenario tests + testHelpers)

Fix all relative import paths (App, storage, __mocks__, testHelpers).
Fix jest.config testMatch globs in shared/ and server/ (rootDir +
<rootDir>/tests pattern).

Delete scripts/repro-pause-bug.js (debug scratch, superseded by
turn.pause-add.test.js).

Keep scripts/replay-combat.js + scripts/audit-rotation.js as manual
demo/exploratory tools (NOT unit tests, not deterministic).

No logic changes. All green: shared 49 + 1 validated RED, server 23,
FE 62. Scenario test unchanged (240s timeout, pre-existing slow).
2026-06-29 16:02:22 -04:00