plugins/hushle/src/__tests__/harness.tsx
Rama main · réplica de lectura del repositorio original.
/**
* Test harness for Hushle's panel: fixtures, the server's per-viewer
* projection, and a real DOM to mount the panel in.
*
* react-dom and happy-dom are this package's devDependencies: the panel
* never needs them at runtime (the host renders it), only these tests do.
*/
import { act, type ReactElement } from 'react';
import { hushleReducer } from '../actions';
import { HushlePanel, type HushlePanelCardPack, type HushlePanelProps } from '../renderClient';
import { createHushleInitialState, type HushleAction, type HushleCard, type HushleState } from '../state';
import type { HushleViewState } from '../ui/model';
// ---------------------------------------------------------------------------
// Fixtures — two teams of two, a floater and a spectator
// ---------------------------------------------------------------------------
export const MIRA = 'u-mira-0001';
export const THEO = 'u-theo-0002';
export const KAYA = 'u-kaya-0003';
export const JUNO = 'u-juno-0004';
export const NOVA = 'u-nova-0005';
export const SAM = 'u-sam-00006';
export const PLAYERS = [
{ userId: MIRA, name: 'Mira' },
{ userId: THEO, name: 'Theo' },
{ userId: KAYA, name: 'Kaya' },
{ userId: JUNO, name: 'Juno' },
{ userId: NOVA, name: 'Nova' },
{ userId: SAM, name: 'Sam' },
];
export const LIGHTHOUSE: HushleCard = {
id: 'card-lighthouse',
language: 'en',
word: 'lighthouse',
forbiddenWords: ['sea', 'light', 'ship', 'tower', 'coast'],
difficulty: 'medium',
category: 'places',
};
export const UMBRELLA: HushleCard = {
id: 'card-umbrella',
language: 'en',
word: 'umbrella',
forbiddenWords: ['rain', 'wet', 'open', 'handle', 'weather'],
difficulty: 'easy',
category: 'objects',
};
export const PYRAMID: HushleCard = {
id: 'card-pyramid',
language: 'en',
word: 'pyramid',
forbiddenWords: ['egypt', 'pharaoh', 'triangle', 'desert', 'tomb'],
difficulty: 'hard',
category: 'history',
};
export function testDeck(size = 12): HushleCard[] {
const cards = [LIGHTHOUSE, UMBRELLA, PYRAMID];
for (let i = cards.length; i < size; i += 1) {
cards.push({
id: `card-${i}`,
language: 'en',
word: `word${i}`,
forbiddenWords: ['a', 'b', 'c'],
difficulty: i % 3 === 0 ? 'hard' : i % 2 === 0 ? 'medium' : 'easy',
category: 'general',
});
}
return cards.slice(0, size);
}
export const PACKS: HushlePanelCardPack[] = [
{ id: 'p-en', slug: 'hushle-en-basic', name: 'Hushle — English (Basic)', language: 'en', cardCount: 24, isBuiltIn: true },
{ id: 'p-tr', slug: 'hushle-tr-basic', name: 'Hushle — Türkçe (Temel)', language: 'tr', cardCount: 24, isBuiltIn: true },
{ id: 'p-de', slug: 'party-de', name: 'Partywörter', language: 'de', cardCount: 40, isBuiltIn: false },
];
export function teamSetupState(opts: { floater?: boolean; teams?: boolean } = {}): HushleState {
let s = createHushleInitialState();
s = hushleReducer(s, {
type: 'start-game',
packId: 'hushle-en-basic',
language: 'en',
createdBy: KAYA,
cardsPerTurn: 3,
deck: testDeck(),
});
if (opts.teams === false) return s;
return hushleReducer(s, {
type: 'set-teams',
teams: [
{ name: 'Ice', playerIds: [MIRA, THEO] },
{ name: 'Amber', playerIds: [KAYA, JUNO] },
],
floaterPlayerId: opts.floater === false ? null : NOVA,
});
}
/** Ice's first turn, Mira explaining "lighthouse". */
export function playingState(): HushleState {
const setup = teamSetupState();
const started = hushleReducer(setup, { type: 'start-turn', teamId: setup.teams[0]!.id, explainerId: MIRA });
// The draw is random; pin the card so the tests can name it.
return {
...started,
currentCard: LIGHTHOUSE,
usedCardIds: [LIGHTHOUSE.id],
};
}
/**
* The server's per-viewer projection (packages/core/src/activity-projection.ts),
* restated for these tests: the deck and the used ids never leave the
* server — counts do — and the card goes only to the explainer and the
* players of the OTHER teams.
*/
export function project(state: HushleState, viewerUserId: string): HushleViewState {
const { deck, usedCardIds, ...rest } = state;
const used = new Set(usedCardIds).size;
const view = {
...rest,
deckSize: deck.length,
cardsRemaining: Math.max(0, deck.length - used),
usedCardCount: used,
} as unknown as HushleViewState;
if (state.phase !== 'ended' && view.currentCard) {
const isExplainer = state.currentExplainerId === viewerUserId;
const isOpponent = state.teams.some(
(team) => team.id !== state.currentTeamId && team.playerIds.includes(viewerUserId)
);
if (!isExplainer && !isOpponent) view.currentCard = null;
}
return view;
}
// ---------------------------------------------------------------------------
// A DOM to mount the panel in
// ---------------------------------------------------------------------------
type Root = { render: (node: ReactElement) => void; unmount: () => void };
interface Dom {
document: Document;
createRoot: (container: Element) => Root;
}
let domPromise: Promise<Dom> | null = null;
async function loadDom(): Promise<Dom> {
const happy = (await import('happy-dom')) as unknown as {
Window: new (options?: { url?: string }) => Window & typeof globalThis;
};
const win = new happy.Window({ url: 'http://localhost/' });
const globals = globalThis as Record<string, unknown>;
for (const key of [
'document',
'navigator',
'HTMLElement',
'HTMLIFrameElement',
'HTMLInputElement',
'Element',
'Node',
'Text',
'Event',
'MouseEvent',
'KeyboardEvent',
'InputEvent',
'FocusEvent',
'getComputedStyle',
]) {
const value = (win as unknown as Record<string, unknown>)[key];
Object.defineProperty(globals, key, {
value: typeof value === 'function' && key === 'getComputedStyle' ? value.bind(win) : value,
configurable: true,
writable: true,
});
}
Object.defineProperty(globals, 'window', { value: win, configurable: true, writable: true });
globals.IS_REACT_ACT_ENVIRONMENT = true;
const client = (await import('react-dom/client')) as unknown as {
createRoot: (container: Element) => Root;
};
return { document: win.document, createRoot: client.createRoot };
}
export function dom(): Promise<Dom> {
domPromise ??= loadDom();
return domPromise;
}
export interface Mounted {
container: HTMLElement;
/** Every action the panel dispatched, in order. */
actions: HushleAction[];
rerender: (props: Partial<HushlePanelProps>) => Promise<void>;
unmount: () => Promise<void>;
text: () => string;
/** The buttons whose accessible name (aria-label, else text) matches. */
buttons: (name: string | RegExp) => HTMLButtonElement[];
button: (name: string | RegExp) => HTMLButtonElement;
click: (element: HTMLElement) => Promise<void>;
type: (input: HTMLInputElement, value: string) => Promise<void>;
submit: (form: HTMLFormElement) => Promise<void>;
input: (label: string) => HTMLInputElement;
}
export function accessibleName(element: Element): string {
return (element.getAttribute('aria-label') ?? element.textContent ?? '').replace(/\s+/g, ' ').trim();
}
/** Mount the real panel, the way the host's PluginSurface does, with a recording dispatch. */
export async function mountPanel(props: Partial<HushlePanelProps> & { state: HushleState }): Promise<Mounted> {
const { document, createRoot } = await dom();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const actions: HushleAction[] = [];
let current: HushlePanelProps = {
dispatch: (action) => {
actions.push(action);
},
actorUserId: KAYA,
hostUserId: KAYA,
players: PLAYERS,
...props,
};
await act(async () => root.render(<HushlePanel {...current} />));
const buttons = (name: string | RegExp) =>
[...container.querySelectorAll('button')].filter((button) => {
const label = accessibleName(button);
return typeof name === 'string' ? label === name : name.test(label);
});
return {
container,
actions,
async rerender(next) {
current = { ...current, ...next };
await act(async () => root.render(<HushlePanel {...current} />));
},
async unmount() {
await act(async () => root.unmount());
container.remove();
},
text: () => (container.textContent ?? '').replace(/\s+/g, ' '),
buttons,
button(name) {
const found = buttons(name);
if (found.length !== 1) {
const all = [...container.querySelectorAll('button')].map(accessibleName);
throw new Error(`expected one button named ${String(name)}, found ${found.length}: ${JSON.stringify(all)}`);
}
return found[0]!;
},
async click(element) {
await act(async () => element.click());
},
async type(input, value) {
await act(async () => {
// React tracks the input's value; set it through the prototype
// setter so the change event is not swallowed.
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value')?.set;
setter?.call(input, value);
input.dispatchEvent(new (input.ownerDocument.defaultView as unknown as typeof globalThis).Event('input', { bubbles: true }));
});
},
async submit(form) {
await act(async () => form.requestSubmit());
},
input(label) {
const match = [...container.querySelectorAll('label')].find((element) => element.textContent?.trim() === label);
const id = match?.getAttribute('for');
const input = id ? (container.ownerDocument.getElementById(id) as HTMLInputElement | null) : null;
if (!input) throw new Error(`no input labelled ${label}`);
return input;
},
};
}