2026-06-29 13:13:46 -04:00
|
|
|
// 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';
|
2026-07-06 16:39:11 -04:00
|
|
|
import { render, waitFor, screen } from '@testing-library/react';
|
2026-06-29 13:13:46 -04:00
|
|
|
import '@testing-library/jest-dom';
|
2026-06-29 16:02:22 -04:00
|
|
|
import App from '../App';
|
|
|
|
|
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
|
|
|
|
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
|
2026-06-29 13:13:46 -04:00
|
|
|
|
|
|
|
|
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
|
2026-07-06 16:39:11 -04:00
|
|
|
function seedActiveDisplay(participants = []) {
|
2026-06-29 13:13:46 -04:00
|
|
|
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: '' });
|
2026-07-06 16:39:11 -04:00
|
|
|
MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
|
2026-06-29 13:13:46 -04:00
|
|
|
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 16:39:11 -04:00
|
|
|
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'] : [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 13:13:46 -04:00
|
|
|
describe('DisplayView characterization', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
window.history.replaceState({}, '', '/display');
|
|
|
|
|
global.alert = jest.fn();
|
|
|
|
|
window.open = jest.fn();
|
|
|
|
|
resetAdapterCalls();
|
2026-07-06 16:39:11 -04:00
|
|
|
Element.prototype.scrollIntoView = jest.fn();
|
2026-06-29 13:13:46 -04:00
|
|
|
});
|
|
|
|
|
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 });
|
|
|
|
|
});
|
2026-07-06 16:39:11 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 22:38:33 -04:00
|
|
|
test('DisplayView hides inactive participants for all types after fade-out', async () => {
|
2026-07-06 16:39:11 -04:00
|
|
|
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());
|
2026-07-06 22:38:33 -04:00
|
|
|
// inactive held during exit animation, removed after transition
|
|
|
|
|
await waitFor(() => expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(), { timeout: 1500 });
|
2026-07-06 16:39:11 -04:00
|
|
|
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
|
|
|
|
|
});
|
2026-06-29 13:13:46 -04:00
|
|
|
});
|