plugins/hushle/src/__tests__/panel.test.tsx
Rama main · réplica de lectura del repositorio original.
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { hushleReducer } from '../actions';
import type { HushleState } from '../state';
import {
JUNO,
KAYA,
LIGHTHOUSE,
MIRA,
NOVA,
PACKS,
PLAYERS,
PYRAMID,
SAM,
THEO,
UMBRELLA,
dom,
mountPanel,
playingState,
project,
teamSetupState,
type Mounted,
} from './harness';
import { createHushleInitialState } from '../state';
/**
* Hushle's panel, rendered for real: every phase, every seat at the table,
* and the buttons — what each dispatches, and the double-tap guards.
*/
let mounted: Mounted | null = null;
const mount = async (...args: Parameters<typeof mountPanel>) => {
mounted = await mountPanel(...args);
return mounted;
};
beforeAll(async () => {
await dom();
});
afterEach(async () => {
await mounted?.unmount();
mounted = null;
document.documentElement.lang = '';
});
const q = (panel: Mounted, selector: string) => panel.container.querySelector(selector);
const qa = (panel: Mounted, selector: string) => [...panel.container.querySelectorAll(selector)];
describe('the shell', () => {
it('sits in the UI kit shell with Hushle\'s own stylesheet, tagged with its phase', async () => {
const panel = await mount({ state: createHushleInitialState() });
const root = q(panel, '.lfui.hushle');
expect(root?.getAttribute('data-phase')).toBe('lobby');
// React hoists both sheets into <head>.
const sheets = [...document.head.querySelectorAll('style')].map((style) => style.textContent ?? '');
expect(sheets.some((css) => css.includes('--lfui-surface'))).toBe(true);
expect(sheets.some((css) => css.includes('--hushle-easy') && css.includes('.lf-theme-light .hushle'))).toBe(true);
});
});
describe('lobby', () => {
it('lets the host pick a pack and every start-game option, then starts with exactly those', async () => {
const panel = await mount({ state: createHushleInitialState(), cardPacks: PACKS });
expect(panel.text()).toContain('Game settings');
// One tile per pack; the one in the viewer's language is picked.
const group = q(panel, '[role="group"][aria-label="Word pack"]')!;
const tiles = [...group.querySelectorAll('button')];
expect(tiles).toHaveLength(3);
expect(tiles.map((tile) => tile.getAttribute('aria-pressed'))).toEqual(['true', 'false', 'false']);
expect(tiles[2]!.textContent).toContain('German');
expect(tiles[2]!.textContent).toContain('40 cards');
expect(tiles[2]!.textContent).toContain('Custom');
expect(tiles[1]!.querySelector('[lang="tr"]')?.textContent).toBe('Hushle — Türkçe (Temel)');
await panel.click(tiles[1]!);
await panel.click(panel.button('45 s'));
await panel.click(panel.button('10'));
await panel.click(panel.button('3'));
await panel.click(panel.button('Harder'));
expect(panel.text()).toContain('20% easy · 40% medium · 40% hard');
await panel.click(panel.button('Start Hushle'));
expect(panel.actions).toEqual([
{
type: 'start-game',
packId: 'hushle-tr-basic',
language: 'tr',
turnDurationSeconds: 45,
cardsPerTurn: 10,
teamSize: 3,
difficultyDistribution: { easy: 0.2, medium: 0.4, hard: 0.4 },
createdBy: KAYA,
},
]);
});
it('starts with the reducer defaults when the host changes nothing', async () => {
const panel = await mount({ state: createHushleInitialState(), cardPacks: PACKS });
await panel.click(panel.button('Start Hushle'));
expect(panel.actions).toEqual([
{
type: 'start-game',
packId: 'hushle-en-basic',
language: 'en',
turnDurationSeconds: 60,
cardsPerTurn: 15,
teamSize: 2,
difficultyDistribution: { easy: 0.6, medium: 0.3, hard: 0.1 },
createdBy: KAYA,
},
]);
});
it('falls back to the built-in languages when there are no packs', async () => {
const panel = await mount({ state: createHushleInitialState(), cardPacks: [] });
expect(q(panel, '[aria-label="Word pack"]')).toBeNull();
const languages = q(panel, '[role="group"][aria-label="Card language"]')!;
// Each language in its own name, tagged with its language.
expect([...languages.querySelectorAll('[lang]')].map((el) => [el.getAttribute('lang'), el.textContent])).toEqual([
['en', 'English'],
['tr', 'Türkçe'],
]);
await panel.click(panel.button('Türkçe'));
await panel.click(panel.button('Start Hushle'));
expect(panel.actions[0]).toMatchObject({ type: 'start-game', packId: 'hushle-tr-basic', language: 'tr' });
});
it('shows everyone else how to play while they wait', async () => {
const panel = await mount({ state: createHushleInitialState(), actorUserId: SAM, cardPacks: PACKS });
expect(panel.buttons('Start Hushle')).toHaveLength(0);
expect(q(panel, '[aria-label="Word pack"]')).toBeNull();
expect(q(panel, '[role="status"]')?.textContent).toContain('Waiting for the host to start the game…');
expect(panel.text()).toContain('How to play');
expect(panel.text()).toContain('a bust costs a point');
expect(panel.text()).not.toContain(SAM);
});
it('tells the host the turn timer covers the whole turn', async () => {
const panel = await mount({ state: createHushleInitialState(), cardPacks: PACKS });
expect(panel.text()).toContain('For the whole turn: as many cards as the team can get before it runs out.');
});
});
describe('team setup', () => {
it('shows the teams, their open seats, the floater and the settings to everyone', async () => {
const state = teamSetupState();
const panel = await mount({ state: project(state, SAM), actorUserId: SAM });
expect(panel.text()).toContain('Team setup');
expect(panel.text()).toContain('Ice');
expect(panel.text()).toContain('Amber');
expect(panel.text()).toContain('2 of 2 players');
expect(panel.text()).toContain('English · 12 cards');
expect(panel.text()).toContain('Nova');
expect(panel.text()).toContain('Floater');
expect(panel.text()).toContain('60 s');
expect(panel.text()).toContain('Mixed');
// Sam is in the room but not seated yet — named, never shown an id.
expect(panel.text()).toContain('Not on a team yet');
expect(panel.text()).toContain('Sam(you)');
expect(panel.text()).not.toContain(SAM);
expect(q(panel, 'form')).toBeNull();
expect(panel.buttons(/Start first turn|Split|Shuffle/)).toHaveLength(0);
expect(q(panel, '[role="status"]')?.textContent).toContain('Waiting for the host to start the first turn…');
});
it('starts the first turn with the first team and its first player', async () => {
const state = teamSetupState();
const panel = await mount({ state: project(state, KAYA) });
expect(panel.text()).toContain('Ice goes first, and Mira explains.');
await panel.click(panel.button('Start first turn'));
// The turn it starts from (0) rides along: a second press is ignored.
expect(panel.actions).toEqual([{ type: 'start-turn', teamId: state.teams[0]!.id, explainerId: MIRA, turnNumber: 0 }]);
});
it('a double press of Start first turn starts one turn', async () => {
const state = teamSetupState();
const panel = await mount({ state: project(state, KAYA) });
const start = panel.button('Start first turn');
await panel.click(start);
await panel.click(start);
expect(panel.actions).toHaveLength(2);
const once = hushleReducer(state, panel.actions[0]!);
expect(once.turnNumber).toBe(1);
expect(hushleReducer(once, panel.actions[1]!)).toBe(once);
});
it('cannot start without a team, and offers to split the room', async () => {
const panel = await mount({ state: project(teamSetupState({ teams: false }), KAYA) });
expect(panel.button('Start first turn').disabled).toBe(true);
expect(panel.text()).toContain('Split the room into two teams, or add a team and seat people one by one.');
expect(panel.button('Split into two teams').disabled).toBe(false);
});
it('splits everyone in the room into two teams, the odd one out floating', async () => {
const random = vi.spyOn(Math, 'random').mockReturnValue(0);
try {
const room = [KAYA, MIRA, THEO, JUNO, NOVA].map((userId) => PLAYERS.find((p) => p.userId === userId)!);
const panel = await mount({ state: project(teamSetupState({ teams: false }), KAYA), players: room });
await panel.click(panel.button('Split into two teams'));
expect(panel.actions).toHaveLength(1);
const action = panel.actions[0] as { type: string; teams: Array<{ name: string; playerIds: string[] }>; floaterPlayerId?: string };
expect(action.type).toBe('set-teams');
expect(action.teams.map((team) => team.name)).toEqual(['Ice', 'Amber']);
expect(action.teams.map((team) => team.playerIds.length)).toEqual([2, 2]);
expect([...action.teams.flatMap((team) => team.playerIds), action.floaterPlayerId].sort()).toEqual(
room.map((p) => p.userId).sort()
);
} finally {
random.mockRestore();
}
});
it('reshuffles the room into the teams already named, leaving out who has left', async () => {
const state = teamSetupState();
// Nova has left the room; the others are here.
const room = PLAYERS.filter((p) => p.userId !== NOVA);
const panel = await mount({ state: project(state, KAYA), players: room });
await panel.click(panel.button('Shuffle the teams'));
const action = panel.actions[0] as { teams: Array<{ name: string; playerIds: string[] }>; floaterPlayerId?: string };
expect(action.teams.map((team) => team.name)).toEqual(['Ice', 'Amber']);
const placed = [...action.teams.flatMap((team) => team.playerIds), action.floaterPlayerId].filter(Boolean);
expect(placed).not.toContain(NOVA);
expect(placed.sort()).toEqual(room.map((p) => p.userId).sort());
});
it('adds an empty team from the form, keeping the teams and the floater already set', async () => {
const state = teamSetupState();
const panel = await mount({ state: project(state, KAYA) });
const add = panel.button('Add team');
expect(add.disabled).toBe(true);
expect(q(panel, 'form')?.querySelectorAll('input')).toHaveLength(1);
await panel.type(panel.input('Team name'), 'Moss');
expect(add.disabled).toBe(false);
await panel.submit(q(panel, 'form') as HTMLFormElement);
expect(panel.actions).toEqual([
{
type: 'set-teams',
teams: [
{ name: 'Ice', playerIds: [MIRA, THEO] },
{ name: 'Amber', playerIds: [KAYA, JUNO] },
{ name: 'Moss', playerIds: [] },
],
floaterPlayerId: NOVA,
},
]);
});
it('sends the classic payload when there is no floater', async () => {
const panel = await mount({ state: project(teamSetupState({ teams: false }), KAYA) });
await panel.type(panel.input('Team name'), 'Ice');
await panel.click(panel.button('Add team'));
expect(panel.actions).toEqual([{ type: 'set-teams', teams: [{ name: 'Ice', playerIds: [] }] }]);
});
it('seats, unseats and removes through set-teams', async () => {
const state = teamSetupState();
const panel = await mount({ state: project(state, KAYA) });
await panel.click(panel.button('Remove Theo from Ice'));
await panel.click(panel.button('Remove Amber'));
await panel.click(panel.button('Remove floater'));
expect(panel.actions).toEqual([
{
type: 'set-teams',
teams: [
{ name: 'Ice', playerIds: [MIRA] },
{ name: 'Amber', playerIds: [KAYA, JUNO] },
],
floaterPlayerId: NOVA,
},
{ type: 'set-teams', teams: [{ name: 'Ice', playerIds: [MIRA, THEO] }], floaterPlayerId: NOVA },
{
type: 'set-teams',
teams: [
{ name: 'Ice', playerIds: [MIRA, THEO] },
{ name: 'Amber', playerIds: [KAYA, JUNO] },
],
},
]);
});
it('seats a person from the room by name on a team with room, or makes them the floater', async () => {
const state = hushleReducer(teamSetupState({ floater: false }), {
type: 'set-teams',
teams: [
{ name: 'Ice', playerIds: [MIRA] },
{ name: 'Amber', playerIds: [KAYA, JUNO] },
],
});
const panel = await mount({ state: project(state, KAYA) });
// Amber is full, so only Ice is offered.
expect(panel.buttons('Add Sam to Amber')).toHaveLength(0);
await panel.click(panel.button('Add Sam to Ice'));
await panel.click(panel.button('Make Sam the floater'));
expect(panel.actions).toEqual([
{
type: 'set-teams',
teams: [
{ name: 'Ice', playerIds: [MIRA, SAM] },
{ name: 'Amber', playerIds: [KAYA, JUNO] },
],
},
{
type: 'set-teams',
teams: [
{ name: 'Ice', playerIds: [MIRA] },
{ name: 'Amber', playerIds: [KAYA, JUNO] },
],
floaterPlayerId: SAM,
},
]);
});
it('only offers seats to people who are in the room', async () => {
const state = hushleReducer(teamSetupState({ floater: false }), {
type: 'set-teams',
teams: [
{ name: 'Ice', playerIds: [MIRA] },
{ name: 'Amber', playerIds: [KAYA] },
],
});
const room = PLAYERS.filter((p) => p.userId !== SAM);
const panel = await mount({ state: project(state, KAYA), players: room });
expect(panel.buttons(/^Add Sam to/)).toHaveLength(0);
expect(panel.buttons('Add Theo to Ice')).toHaveLength(1);
});
it('keeps the names of seated players who step out, and never shows an id', async () => {
const state = teamSetupState();
const panel = await mount({ state: project(state, KAYA) });
// Theo leaves the room: his seat keeps his name.
await panel.rerender({ players: PLAYERS.filter((p) => p.userId !== THEO) });
expect(panel.text()).toContain('Theo');
// Someone the panel never had a name for.
const stranger = await mountPanel({ state: project(state, KAYA), players: [{ userId: KAYA, name: 'Kaya' }] });
expect(stranger.text()).toContain('Unknown player');
expect(stranger.text()).not.toContain(MIRA);
await stranger.unmount();
});
});
describe('a running turn, seat by seat', () => {
const state = playingState();
const view = (viewer: string, hostUserId = KAYA) => mount({ state: project(state, viewer), actorUserId: viewer, hostUserId });
it('the explainer sees the whole card: word, category, difficulty, forbidden words', async () => {
const panel = await view(MIRA);
expect(panel.text()).toContain("You're explaining");
const card = q(panel, 'article[aria-label="Word card"]')!;
expect(card).not.toBeNull();
const word = card.querySelector('.hushle-word')!;
expect(word.textContent).toBe('lighthouse');
// The word speaks the PACK's language, whatever the panel's.
expect(word.getAttribute('lang')).toBe('en');
const forbidden = card.querySelector('ul')!;
expect(forbidden.getAttribute('lang')).toBe('en');
expect([...forbidden.querySelectorAll('li')].map((li) => li.textContent)).toEqual(LIGHTHOUSE.forbiddenWords);
expect(document.getElementById(forbidden.getAttribute('aria-labelledby')!)?.textContent).toBe("Don't say");
expect(card.textContent).toContain('Places');
// Difficulty: named, and told by pips — never colour alone.
expect(card.textContent).toContain('Medium');
expect(card.querySelectorAll('[data-pip="on"]')).toHaveLength(2);
// Not the host, not an opponent: nothing to press.
expect(panel.buttons(/Got it|Skip|Penalty|BUST/)).toHaveLength(0);
expect(panel.text()).toContain('The host scores each card.');
});
it('the explaining team does not get the card — they listen', async () => {
const panel = await view(THEO);
expect(q(panel, 'article[aria-label="Word card"]')).toBeNull();
expect(panel.text()).not.toContain('lighthouse');
expect(panel.text()).toContain("You're guessing");
expect(panel.text()).toContain('Listen to Mira');
expect(panel.text()).toContain('Shout your guesses in voice.');
expect(panel.buttons(/BUST/)).toHaveLength(0);
});
it('the other team watches the card and can bust — once per card', async () => {
const panel = await view(JUNO);
expect(panel.text()).toContain("You're on the other team");
expect(q(panel, '.hushle-word')?.textContent).toBe('lighthouse');
const bust = panel.button('BUST! Forbidden word');
expect(document.getElementById(bust.getAttribute('aria-describedby')!)?.textContent).toContain('press BUST');
expect(panel.buttons(/Got it|Skip|Penalty/)).toHaveLength(0);
await panel.click(bust);
await panel.click(bust);
// The card id scopes the BUST (a stale one is ignored by the reducer).
expect(panel.actions).toEqual([{ type: 'bust-forbidden', cardId: state.currentCard!.id }]);
expect(bust.disabled).toBe(true);
// The bust rotates the card; a new card re-arms the button.
const busted = hushleReducer(state, { type: 'bust-forbidden', bustedBy: JUNO, cardId: state.currentCard!.id });
await panel.rerender({ state: project({ ...busted, currentCard: UMBRELLA }, JUNO) });
expect(panel.button('BUST! Forbidden word').disabled).toBe(false);
expect(q(panel, '.hushle-word')?.textContent).toBe('umbrella');
});
it('the host scores every card, and a double tap scores once', async () => {
const panel = await view(SAM, SAM);
const gotIt = panel.button('Got it');
await panel.click(gotIt);
await panel.click(gotIt);
await panel.click(panel.button('Skip'));
// Each press names the deal it was drawn for (B-06, stale intent).
expect(panel.actions).toEqual([{ type: 'correct-guess', cardSeq: state.cardSeq }]);
expect(gotIt.disabled).toBe(true);
// The next card unlocks the host's buttons.
const next = { ...hushleReducer(state, { type: 'correct-guess' }), currentCard: PYRAMID };
await panel.rerender({ state: project(next, SAM) });
await panel.click(panel.button('Skip'));
const afterSkip = { ...hushleReducer(next, { type: 'pass' }), currentCard: UMBRELLA };
await panel.rerender({ state: project(afterSkip, SAM) });
await panel.click(panel.button('Penalty'));
expect(panel.actions).toEqual([
{ type: 'correct-guess', cardSeq: state.cardSeq },
{ type: 'pass', cardSeq: next.cardSeq },
{ type: 'penalty', cardSeq: afterSkip.cardSeq },
]);
// Three different deals, and none of them reveals the card.
expect(new Set([state.cardSeq, next.cardSeq, afterSkip.cardSeq]).size).toBe(3);
});
it('a host who cannot see the card still names it: the deal number, never the card id', async () => {
const panel = await view(SAM, SAM);
expect(q(panel, 'article[aria-label="Word card"]')).toBeNull();
await panel.click(panel.button('Got it'));
const [sent] = panel.actions;
expect(sent).toEqual({ type: 'correct-guess', cardSeq: state.cardSeq });
expect(JSON.stringify(panel.actions)).not.toContain(LIGHTHOUSE.id);
});
it('a press the reducer receives after a BUST moved the card on scores nothing', async () => {
const panel = await view(SAM, SAM);
await panel.click(panel.button('Got it'));
const [gotIt] = panel.actions;
// Juno's BUST reaches the server first: Lighthouse costs a point, a new card is dealt.
const busted = hushleReducer(state, { type: 'bust-forbidden', bustedBy: JUNO, cardId: LIGHTHOUSE.id });
expect(hushleReducer(busted, gotIt!)).toBe(busted);
});
it('a session from before deal numbers (read raw) sends the classic payload', async () => {
const legacy = { ...project(state, SAM), cardSeq: undefined } as unknown as ReturnType<typeof project>;
const panel = await mount({ state: legacy, actorUserId: SAM, hostUserId: SAM });
await panel.click(panel.button('Got it'));
expect(panel.actions).toEqual([{ type: 'correct-guess' }]);
});
it('a host on the watching team busts instead of a second penalty button', async () => {
const panel = await view(KAYA);
expect(panel.text()).toContain("You're on the other team");
expect(panel.buttons('Got it')).toHaveLength(1);
expect(panel.buttons('Skip')).toHaveLength(1);
expect(panel.buttons('Penalty')).toHaveLength(0);
await panel.click(panel.button('BUST! Forbidden word'));
expect(panel.actions).toEqual([{ type: 'bust-forbidden', cardId: state.currentCard!.id }]);
});
it('the host keeps the pace: next card, explainer, end turn, end game', async () => {
const panel = await view(KAYA);
expect(panel.text()).toContain('Host tools');
await panel.click(panel.button('Next card'));
const explainer = q(panel, '[role="group"][aria-label="Explainer"]')!;
const options = [...explainer.querySelectorAll('button')];
// Ice's rotation, in order: Mira, the floater's slot, Theo.
expect(options.map((button) => [button.textContent, button.getAttribute('aria-pressed')])).toEqual([
['Mira', 'true'],
['Nova', 'false'],
['Theo', 'false'],
]);
await panel.click(options[0]!);
await panel.click(options[2]!);
await panel.click(panel.button('End turn'));
await panel.click(panel.button('End game'));
expect(panel.actions).toEqual([
{ type: 'next-card', cardSeq: state.cardSeq },
{ type: 'set-explainer', explainerId: THEO, turnNumber: state.turnNumber },
{ type: 'end-turn', turnNumber: state.turnNumber },
{ type: 'end-game' },
]);
});
it('End turn pressed twice ends ONE turn', async () => {
const host = await view(KAYA);
const endTurn = host.button('End turn');
await host.click(endTurn);
await host.click(endTurn);
expect(host.actions).toEqual([
{ type: 'end-turn', turnNumber: state.turnNumber },
{ type: 'end-turn', turnNumber: state.turnNumber },
]);
const after = host.actions.reduce((s, action) => hushleReducer(s, action), state);
// Ice's turn ended once; Amber's turn runs.
expect(after.turnNumber).toBe(state.turnNumber + 1);
expect(after.currentTeamId).toBe(state.teams[1]!.id);
expect(after.timer.startedAt).not.toBeNull();
});
it('a host who plays on the explaining team scores blind', async () => {
const panel = await view(THEO, THEO);
expect(q(panel, 'article[aria-label="Word card"]')).toBeNull();
expect(panel.text()).toContain('Listen to Mira');
expect(panel.buttons('Got it')).toHaveLength(1);
expect(panel.buttons(/BUST/)).toHaveLength(0);
});
it('a host who does not play sees the table, not the card', async () => {
const panel = await view(SAM, SAM);
expect(panel.text()).toContain("You're hosting");
expect(panel.text()).toContain('Mira is explaining');
expect(q(panel, 'article[aria-label="Word card"]')).toBeNull();
expect(panel.buttons('Got it')).toHaveLength(1);
});
it('the floater sits this turn out, marked as the floater', async () => {
const panel = await view(NOVA);
expect(panel.text()).toContain("You're the floater");
expect(panel.text()).toContain('Mira is explaining');
expect(panel.text()).toContain('Only the explainer and the other team can see the card.');
expect(panel.text()).toContain('NovaFloater(you)');
expect(panel.buttons(/BUST|Got it/)).toHaveLength(0);
});
it('a spectator watches', async () => {
const panel = await view(SAM);
expect(panel.text()).toContain("You're watching");
expect(q(panel, 'article[aria-label="Word card"]')).toBeNull();
expect(panel.buttons(/BUST|Got it|End turn/)).toHaveLength(0);
});
it('shows the turn, the timer, the scores and who does what', async () => {
const panel = await view(SAM);
expect(panel.text()).toContain('Turn 1');
expect(panel.text()).toContain('Hosted by Kaya');
expect(panel.text()).toContain('11 cards left');
const timer = q(panel, '[role="timer"]')!;
expect(timer.getAttribute('aria-label')).toMatch(/^\d+ seconds? left$/);
const board = q(panel, 'section[aria-label="Scores"]')!;
expect(board.textContent).toContain('Their turn');
expect(board.textContent).toContain('0 points');
expect(board.textContent).toContain('MiraExplaining');
expect(board.textContent).toContain('TheoGuessing');
expect(board.textContent).toContain('JunoWatching');
expect(panel.text()).toContain('Ice guesses');
expect(panel.text()).toContain('Amber watches the card');
expect(panel.text()).toContain('Card 1 of 3');
});
it('ends the turn\'s scoring when the time is up, and offers the next turn', async () => {
const started = Date.now() - 120_000;
const late = {
...state,
timer: {
...state.timer,
startedAt: new Date(started).toISOString(),
endsAt: new Date(started + state.timer.durationSeconds * 1000).toISOString(),
},
};
const host = await mount({ state: project(late, KAYA), actorUserId: KAYA });
expect(host.text()).toContain("Time's up");
expect(host.text()).toContain('Next up: Amber');
expect(q(host, '[role="timer"]')).toBeNull();
expect(q(host, 'article[aria-label="Word card"]')).toBeNull();
expect(host.buttons(/Got it|Skip|Penalty|BUST|Next card|End turn/)).toHaveLength(0);
await host.click(host.button('Start next turn'));
expect(host.actions).toEqual([{ type: 'end-turn', turnNumber: late.turnNumber }]);
await host.unmount();
const player = await mount({ state: project(late, MIRA), actorUserId: MIRA });
expect(player.text()).toContain("Time's up");
expect(q(player, '[role="status"]')?.textContent).toContain('Waiting for the host to start the next turn…');
});
it('counts every client down to the deadline in state', async () => {
const soon = Date.now() + 30_000;
const turn = { ...state, timer: { ...state.timer, endsAt: new Date(soon).toISOString() } };
const panel = await mount({ state: project(turn, SAM), actorUserId: SAM });
const label = q(panel, '[role="timer"]')?.getAttribute('aria-label') ?? '';
const seconds = Number(label.split(' ')[0]);
expect(seconds).toBeGreaterThanOrEqual(29);
expect(seconds).toBeLessThanOrEqual(30);
});
});
describe('this turn', () => {
it('lists each card and what became of it, words only for those who saw them', async () => {
const s0 = playingState();
const s1 = { ...hushleReducer(s0, { type: 'correct-guess' }), currentCard: UMBRELLA };
const s2 = { ...hushleReducer(s1, { type: 'bust-forbidden', bustedBy: JUNO, cardId: s1.currentCard!.id }), currentCard: PYRAMID };
const opponent = await mount({ state: project(s0, JUNO), actorUserId: JUNO });
expect(opponent.text()).toContain('No cards played yet this turn.');
await opponent.rerender({ state: project(s1, JUNO) });
await opponent.rerender({ state: project(s2, JUNO) });
const chips = qa(opponent, 'li').filter((li) => li.textContent?.includes('·'));
expect(chips.map((li) => li.textContent)).toEqual(['lighthouse·Got it', 'umbrella·Bust']);
expect(chips[0]!.querySelector('[lang="en"]')?.textContent).toBe('lighthouse');
expect(opponent.text()).toContain('Card 3 of 3');
await opponent.unmount();
const guesser = await mount({ state: project(s0, THEO), actorUserId: THEO });
await guesser.rerender({ state: project(s1, THEO) });
await guesser.rerender({ state: project(s2, THEO) });
expect(guesser.text()).toContain('Got it');
expect(guesser.text()).toContain('Bust');
expect(guesser.text()).not.toContain('lighthouse');
expect(guesser.text()).not.toContain('umbrella');
});
});
describe('between turns', () => {
/** Ice has played its three cards; the turn is over. */
const betweenTurns = (): HushleState => {
let s = playingState();
for (let i = 0; i < 3; i += 1) s = hushleReducer(s, { type: 'correct-guess' });
return s;
};
it('says who goes next, and gives the host the way on', async () => {
const state = betweenTurns();
expect(state.phase).toBe('playing');
const panel = await mount({ state: project(state, KAYA) });
expect(panel.text()).toContain('Turn over');
expect(panel.text()).toContain('Next up: Amber');
// The rotation's pick for Amber (see nextTurnPreview).
expect(panel.text()).toContain('Kaya explains next.');
expect(q(panel, '[role="timer"]')).toBeNull();
expect(panel.buttons(/Got it|BUST/)).toHaveLength(0);
await panel.click(panel.button('Start next turn'));
expect(panel.actions).toEqual([{ type: 'end-turn', turnNumber: state.turnNumber }]);
// A second press names the turn that just ended: Amber's turn is not cut short.
await panel.click(panel.button('Start next turn'));
const once = hushleReducer(state, panel.actions[0]!);
expect(hushleReducer(once, panel.actions[1]!)).toBe(once);
});
it('keeps the players waiting for the host', async () => {
const panel = await mount({ state: project(betweenTurns(), JUNO), actorUserId: JUNO });
expect(q(panel, '[role="status"]')?.textContent).toContain('Waiting for the host to start the next turn…');
expect(panel.buttons('Start next turn')).toHaveLength(0);
});
it('stops at an empty deck', async () => {
const state = { ...betweenTurns() };
state.usedCardIds = state.deck.map((card) => card.id);
const panel = await mount({ state: project(state, KAYA) });
expect(panel.text()).toContain('The deck is empty');
expect(panel.text()).toContain('0 cards left');
expect(panel.button('Start next turn').disabled).toBe(true);
await panel.click(panel.button('End game'));
expect(panel.actions).toEqual([{ type: 'end-game' }]);
});
});
describe('the end', () => {
const ended = (ice: number, amber: number, played = 8): HushleState => {
const s = hushleReducer(playingState(), { type: 'end-game' });
return {
...s,
totalCardsPlayed: played,
teams: [
{ ...s.teams[0]!, score: ice, correctCount: 3, passCount: 1, penaltyCount: 1 },
{ ...s.teams[1]!, score: amber, correctCount: 4, passCount: 0, penaltyCount: 1 },
],
};
};
it('crowns the winner, lists the final scores and recaps the game', async () => {
const panel = await mount({ state: project(ended(2, 3), JUNO), actorUserId: JUNO });
expect(panel.text()).toContain('Game over');
expect(q(panel, '[role="status"]')?.textContent).toBe('Amber wins!');
expect(panel.text()).toContain('Final score: 3 points');
const rows = qa(panel, 'ol[aria-label="Final scores"] li');
expect(rows.map((row) => row.textContent)).toEqual([
'1Amber4 guessed · 0 skipped · 1 busted3',
'2Ice3 guessed · 1 skipped · 1 busted2',
]);
expect(panel.text()).toContain('Cards played8');
expect(panel.text()).toContain('Guessed7');
expect(panel.text()).toContain('Busted2');
expect(panel.buttons('Start new game')).toHaveLength(0);
expect(panel.text()).toContain('The host can start a new game.');
});
it('calls a tie a tie', async () => {
const panel = await mount({ state: project(ended(2, 2), JUNO), actorUserId: JUNO });
expect(panel.text()).toContain("It's a tie!");
expect(panel.text()).toContain('Ice and Amber share the win with 2 points.');
});
it('has no winner when no card was played', async () => {
const panel = await mount({ state: project(ended(0, 0, 0), JUNO), actorUserId: JUNO });
expect(panel.text()).toContain('No cards were played');
});
it('lets the host start again with the same pack and settings', async () => {
const state = ended(2, 3);
const panel = await mount({ state: project(state, KAYA) });
await panel.click(panel.button('Start new game'));
expect(panel.actions).toEqual([
{
type: 'start-game',
packId: 'hushle-en-basic',
language: 'en',
turnDurationSeconds: state.settings.turnDurationSeconds,
cardsPerTurn: state.settings.cardsPerTurn,
teamSize: state.settings.teamSize,
difficultyDistribution: state.settings.difficultyDistribution,
createdBy: KAYA,
},
]);
});
});
describe('language', () => {
it('speaks the viewer\'s language while the card keeps its own', async () => {
document.documentElement.lang = 'tr';
const panel = await mount({ state: project(playingState(), MIRA), actorUserId: MIRA });
expect(panel.text()).toContain('Anlatan sensin');
expect(panel.text()).toContain('Yasaklı kelimeler');
expect(panel.text()).toContain('Orta');
expect(q(panel, '.hushle-word')?.getAttribute('lang')).toBe('en');
expect(panel.text()).not.toMatch(/hushle\.[a-z]/);
});
it('never shows a raw key, in any phase or seat', async () => {
const setup = teamSetupState();
const playing = playingState();
const over = hushleReducer(playing, { type: 'end-game' });
for (const lang of ['en', 'tr']) {
document.documentElement.lang = lang;
for (const [state, viewer, host] of [
[createHushleInitialState(), KAYA, KAYA],
[createHushleInitialState(), SAM, KAYA],
[setup, KAYA, KAYA],
[setup, SAM, KAYA],
[playing, MIRA, KAYA],
[playing, THEO, THEO],
[playing, JUNO, KAYA],
[playing, NOVA, KAYA],
[playing, SAM, SAM],
[over, KAYA, KAYA],
] as const) {
const panel = await mountPanel({ state: project(state, viewer), actorUserId: viewer, hostUserId: host, players: PLAYERS });
expect(panel.text(), `${lang} ${state.phase} as ${viewer}`).not.toMatch(/hushle\.[a-zA-Z]/);
await panel.unmount();
}
}
});
});