Files
ttrpg-initiative-tracker/src/tests/DisplayView.characterization.test.js
T
david raistrick 1d4ec873dc Implement D&D 5e death-save state machine and cleanup combat ordering
- Add status-driven death-save model:
  - status: conscious | dying | stable | dead
  - deathSaveSuccesses/deathSaveFailures as display counters
  - remove old death-save fields as source of truth
- Add death-save actions:
  - success, fail, nat1, nat20
  - stabilize participant
  - revive dead participant to 0 HP, stable, unconscious
- Apply 5e HP transition rules:
  - characters and NPCs at 0 HP become dying
  - stable/dying participants gain unconscious condition
  - healing clears death saves and returns conscious
  - massive damage kills
  - non-NPC monsters at 0 HP become dead and inactive
- Split NPCs from monsters with type="npc":
  - NPCs use death saves like characters
  - NPCs remain DM-controlled/monster-colored in UI
  - new NPCs no longer persist isNpc flag
- Keep dead PCs/NPCs in encounter and initiative until DM removes or deactivates
- Allow DM active/inactive toggle for dead participants
- Hide inactive participants from player display
- Improve DM and player death-state UI:
  - show Dying/Dead/Unconscious states consistently
  - add revive button for dead participants
  - distinguish dead visual from inactive visual in DM view
- Fix initiative drag/reorder behavior:
  - downward drag now changes order instead of no-op
  - paused combat can reorder across current turn pointer
  - turnOrderIds stay synced to participants order
- Persist campaign collapse state in localStorage
- Update death-save docs and encounter builder docs
- Add/expand tests for:
  - death saves and HP transitions
  - dead/active/inactive behavior
  - NPC death-save behavior
  - player display visibility
  - drag reorder semantics
  - logging expectations

Tests:
- npm run test:all
- app: 94 passed
- shared: 158 passed, 5 skipped
- server: 32 passed
2026-07-06 16:48:50 -04:00

110 lines
4.6 KiB
JavaScript

// DisplayView.characterization.test.js
// Lock DisplayView uses storage adapter (subscribeDoc), NOT raw SDK onSnapshot(doc(db)).
// Blind spot caught: M2 refactor missed DisplayView; raw SDK + ws stub db = crash.
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
import React from 'react';
import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
function seedActiveDisplay(participants = []) {
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
}
function participant(status) {
return {
id: status,
name: status === 'dying' ? 'Rogue' : status,
type: 'character',
initiative: 18,
maxHp: 160,
currentHp: status === 'conscious' ? 10 : 0,
isActive: true,
status,
deathSaveSuccesses: 0,
deathSaveFailures: 0,
conditions: status === 'stable' || status === 'dying' ? ['unconscious'] : [],
};
}
describe('DisplayView characterization', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/display');
global.alert = jest.fn();
window.open = jest.fn();
resetAdapterCalls();
Element.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
window.history.replaceState({}, '', '/');
});
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
seedActiveDisplay();
render(<App />);
// wait for DisplayView to mount and attempt subscriptions
await waitFor(() => {
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc');
expect(subs.length).toBeGreaterThanOrEqual(1);
}, { timeout: 3000 });
// must subscribe to campaign doc (for background url) and encounter doc
const docSubs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc').map(c => c.path);
expect(docSubs.some(p => p.includes('/campaigns/c1'))).toBe(true);
expect(docSubs.some(p => p.includes('/encounters/e1'))).toBe(true);
});
test('DisplayView also subscribes to activeDisplay status doc via adapter', async () => {
seedActiveDisplay();
render(<App />);
await waitFor(() => {
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc' && c.path.includes('activeDisplay'));
expect(subs.length).toBeGreaterThanOrEqual(1);
}, { timeout: 3000 });
});
test('DisplayView shows Dying for dying character with Unconscious condition', async () => {
seedActiveDisplay([participant('dying')]);
render(<App />);
await waitFor(() => expect(screen.getByText('Rogue')).toBeInTheDocument());
expect(screen.getByText(/Dying/i)).toBeInTheDocument();
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
});
test('DisplayView shows Stable as Unconscious, and Dead as Dead', async () => {
seedActiveDisplay([participant('stable'), participant('dead')]);
render(<App />);
await waitFor(() => expect(screen.getByText('stable')).toBeInTheDocument());
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
expect(screen.queryByText('(Stable)')).not.toBeInTheDocument();
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
});
test('DisplayView hides inactive participants for all types', async () => {
seedActiveDisplay([
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
{ id: 'inactive-monster', name: 'Inactive Monster', type: 'monster', initiative: 10, maxHp: 10, currentHp: 10, isActive: false, status: 'conscious', conditions: [] },
]);
render(<App />);
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument();
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
});
});