Ctrl K
ComunidadGuías…

plugins/hushle/src/__tests__/panel-model.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, expect, it } from 'vitest';
import { hushleReducer } from '../actions';
import { HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION, createHushleInitialState, type HushleState } from '../state';
import {
  DIFFICULTY_PRESETS,
  EMPTY_TURN_LOG,
  advanceTurnLog,
  cardMoveIntent,
  draftOf,
  explainerCandidates,
  gameTotals,
  isTurnRunning,
  nextTurnPreview,
  percentages,
  playRole,
  presetFor,
  seatedIds,
  setTeamsAction,
  setupFromSettings,
  splitIntoTeams,
  standings,
  startGameAction,
  turnDeadline,
  turnMoveIntent,
  turnNumber,
  withPlayerAdded,
  withPlayerRemoved,
  withoutTeam,
  type TurnLog,
} from '../ui/model';
import { JUNO, KAYA, LIGHTHOUSE, MIRA, NOVA, SAM, THEO, playingState, project, teamSetupState, testDeck } from './harness';

describe('who the viewer is this turn', () => {
  const state = playingState();

  it('matches the roles the server projection uses', () => {
    expect(playRole(state, MIRA)).toBe('explainer');
    expect(playRole(state, THEO)).toBe('guesser');
    expect(playRole(state, KAYA)).toBe('opponent');
    expect(playRole(state, JUNO)).toBe('opponent');
    expect(playRole(state, NOVA)).toBe('floater');
    expect(playRole(state, SAM)).toBe('spectator');
  });

  it('gives the card to exactly the viewers that role says can see it', () => {
    for (const viewer of [MIRA, THEO, KAYA, JUNO, NOVA, SAM]) {
      const seesCard = project(state, viewer).currentCard !== null;
      expect(seesCard).toBe(['explainer', 'opponent'].includes(playRole(state, viewer)));
    }
  });

  it('lets the host hand the turn to anyone in the explaining team\'s rotation', () => {
    // Ice's rotation: Mira, the floater's slot, Theo.
    expect(explainerCandidates(state)).toEqual([MIRA, NOVA, THEO]);
  });
});

describe('turn and timer', () => {
  const at = (iso: string) => Date.parse(iso);

  it('counts down to the deadline in state, and not while paused or between turns', () => {
    const start = '2026-01-01T00:00:00.000Z';
    expect(turnDeadline({ startedAt: start, durationSeconds: 60, paused: false, endsAt: '2026-01-01T00:01:00.000Z' })).toBe(
      at('2026-01-01T00:01:00.000Z')
    );
    // The deadline wins over start + duration: every client counts to the same moment.
    expect(turnDeadline({ startedAt: start, durationSeconds: 60, paused: false, endsAt: '2026-01-01T00:00:45.000Z' })).toBe(
      at('2026-01-01T00:00:45.000Z')
    );
    // A timer written before deadlines were stored counts from its start.
    expect(turnDeadline({ startedAt: start, durationSeconds: 60, paused: false, endsAt: null })).toBe(
      at('2026-01-01T00:01:00.000Z')
    );
    expect(turnDeadline({ startedAt: start, durationSeconds: 60, paused: true, endsAt: null })).toBeNull();
    expect(turnDeadline({ startedAt: null, durationSeconds: 60, paused: false, endsAt: null })).toBeNull();
    expect(turnDeadline({ startedAt: 'not a date', durationSeconds: 60, paused: false, endsAt: null })).toBeNull();
    expect(turnDeadline(undefined)).toBeNull();
  });

  it('keeps one deadline for the whole turn, card after card', () => {
    const first = playingState();
    const later = hushleReducer(hushleReducer(first, { type: 'correct-guess' }), { type: 'pass' });
    expect(turnDeadline(later.timer)).toBe(turnDeadline(first.timer));
    expect(turnDeadline(first.timer)! - Date.parse(first.timer.startedAt!)).toBe(first.settings.turnDurationSeconds * 1000);
  });

  it('tells a running turn from the pause between turns', () => {
    const running = playingState();
    expect(isTurnRunning(running)).toBe(true);
    let s = running;
    for (let i = 0; i < s.settings.cardsPerTurn; i += 1) s = hushleReducer(s, { type: 'correct-guess' });
    expect(s.phase).toBe('playing');
    expect(isTurnRunning(s)).toBe(false);
    expect(isTurnRunning(teamSetupState())).toBe(false);
  });

  it("names the deal a host move is for, from every viewer's snapshot alike", () => {
    const state = playingState();
    // The deal number is public — the host, the guessers and spectators get
    // it with no card — and it is what the host's card moves send.
    for (const viewer of [MIRA, THEO, KAYA, NOVA, SAM]) {
      expect(cardMoveIntent(project(state, viewer))).toEqual({ cardSeq: state.cardSeq });
    }
    const next = hushleReducer(state, { type: 'correct-guess', ...cardMoveIntent(state) });
    expect(next.cardSeq).toBe(state.cardSeq + 1);
    expect(cardMoveIntent(next)).toEqual({ cardSeq: next.cardSeq });
    // A snapshot from before deal numbers sends none; so does garbage.
    for (const cardSeq of [undefined, null, -1, 1.5, '3']) {
      expect(cardMoveIntent({ cardSeq } as unknown as HushleState)).toEqual({});
    }
  });

  it("names the turn a host's turn move is for, from the snapshot", () => {
    const setup = teamSetupState();
    expect(turnMoveIntent(project(setup, KAYA))).toEqual({ turnNumber: 0 });
    const first = playingState();
    for (const viewer of [MIRA, THEO, KAYA, NOVA, SAM]) {
      expect(turnMoveIntent(project(first, viewer))).toEqual({ turnNumber: first.turnNumber });
    }
    const second = hushleReducer(first, { type: 'end-turn', ...turnMoveIntent(first) });
    expect(second.turnNumber).toBe(first.turnNumber + 1);
    expect(hushleReducer(second, { type: 'end-turn', ...turnMoveIntent(first) })).toBe(second);
    for (const turnNumber of [undefined, null, -1, 1.5, '2']) {
      expect(turnMoveIntent({ turnNumber } as unknown as HushleState)).toEqual({});
    }
  });

  it('numbers turns as the reducer counts them', () => {
    const first = playingState();
    expect(turnNumber(first)).toBe(1);
    expect(turnNumber(hushleReducer(first, { type: 'end-turn' }))).toBe(2);
  });

  it('previews exactly who end-turn hands the next turn to, round after round', () => {
    let s: HushleState = playingState();
    const explainers = [s.currentExplainerId];
    for (let turn = 0; turn < 6; turn += 1) {
      const preview = nextTurnPreview(s);
      const after = hushleReducer(s, { type: 'end-turn' });
      expect(preview?.team.id).toBe(after.currentTeamId);
      expect(preview?.explainerId).toBe(after.currentExplainerId);
      explainers.push(after.currentExplainerId);
      s = after;
    }
    // Two teams of two and a floater: everyone explains, the floater once per team.
    expect(explainers).toEqual([MIRA, KAYA, NOVA, JUNO, THEO, NOVA, MIRA]);
  });

  it('previews the floater for a team with nobody to explain', () => {
    let s = createHushleInitialState();
    s = hushleReducer(s, { type: 'start-game', packId: 'hushle-en-basic', createdBy: KAYA, deck: testDeck() });
    s = hushleReducer(s, {
      type: 'set-teams',
      teams: [
        { name: 'A', playerIds: [KAYA] },
        { name: 'B', playerIds: [] },
      ],
      floaterPlayerId: NOVA,
    });
    s = hushleReducer(s, { type: 'start-turn', teamId: s.teams[0]!.id, explainerId: KAYA });
    const after = hushleReducer(s, { type: 'end-turn' });
    expect(nextTurnPreview(s)).toEqual({ team: s.teams[1], explainerId: NOVA });
    expect(after.currentExplainerId).toBe(NOVA);
  });
});

describe('scores', () => {
  const withScores = (ice: number, amber: number) => {
    const s = playingState();
    return {
      ...s,
      teams: [
        { ...s.teams[0]!, score: ice, correctCount: ice + 1, passCount: 2, penaltyCount: 1 },
        { ...s.teams[1]!, score: amber, correctCount: amber, passCount: 1, penaltyCount: 0 },
      ],
      totalCardsPlayed: 9,
    };
  };

  it('ranks teams and names the leader', () => {
    const { ranked, leaders } = standings(withScores(1, 3).teams);
    expect(ranked.map((team) => team.name)).toEqual(['Amber', 'Ice']);
    expect(leaders.map((team) => team.name)).toEqual(['Amber']);
  });

  it('keeps every team that shares the top score, in seat order', () => {
    const { ranked, leaders } = standings(withScores(2, 2).teams);
    expect(ranked.map((team) => team.name)).toEqual(['Ice', 'Amber']);
    expect(leaders.map((team) => team.name)).toEqual(['Ice', 'Amber']);
    expect(standings([]).leaders).toEqual([]);
  });

  it('adds up the game', () => {
    expect(gameTotals(withScores(1, 3))).toEqual({ played: 9, guessed: 5, skipped: 3, busted: 1 });
  });
});

describe('lobby settings', () => {
  it('offers the reducer default as "mixed", and names presets back', () => {
    expect(DIFFICULTY_PRESETS.mixed).toEqual(HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION);
    expect(presetFor(HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION)).toBe('mixed');
    expect(presetFor({ easy: 0.8, medium: 0.2, hard: 0 })).toBe('easier');
    expect(presetFor({ easy: 0.5, medium: 0.5, hard: 0 })).toBeNull();
    expect(percentages(DIFFICULTY_PRESETS.harder)).toEqual({ easy: 20, medium: 40, hard: 40 });
  });

  it('starts a game the reducer takes as-is', () => {
    const action = startGameAction(
      {
        packId: 'hushle-tr-basic',
        language: 'tr',
        turnDurationSeconds: 45,
        cardsPerTurn: 10,
        teamSize: 3,
        difficultyDistribution: DIFFICULTY_PRESETS.harder,
      },
      KAYA
    );
    expect(action).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,
    });
    const next = hushleReducer(createHushleInitialState(), action);
    expect(next.phase).toBe('team_setup');
    expect(next.settings).toMatchObject({
      packId: 'hushle-tr-basic',
      language: 'tr',
      turnDurationSeconds: 45,
      cardsPerTurn: 10,
      teamSize: 3,
    });
    expect(presetFor(next.settings.difficultyDistribution)).toBe('harder');
  });

  it('starts the next game with the settings of the last one', () => {
    const played = playingState();
    const again = hushleReducer(played, startGameAction(setupFromSettings(played.settings), KAYA));
    const { difficultyDistribution, ...rest } = again.settings;
    const { difficultyDistribution: before, ...restBefore } = played.settings;
    expect(rest).toEqual(restBefore);
    // The reducer renormalises the weights, so compare them loosely.
    for (const tier of ['easy', 'medium', 'hard'] as const) {
      expect(difficultyDistribution[tier]).toBeCloseTo(before[tier], 9);
    }
    expect(presetFor(difficultyDistribution)).toBe('mixed');
  });
});

describe('team edits — set-teams always carries the whole roster', () => {
  const state = teamSetupState();

  it('splits the room into two even teams, the odd one out floating', () => {
    const names: [string, string] = ['Ice', 'Amber'];
    const room = [MIRA, THEO, KAYA, JUNO, NOVA];
    const { teams, floaterPlayerId } = splitIntoTeams(room, 2, names);
    expect(teams.map((team) => team.name)).toEqual(names);
    expect(teams.map((team) => team.playerIds.length)).toEqual([2, 2]);
    // Everyone is placed exactly once.
    expect([...teams.flatMap((team) => team.playerIds), floaterPlayerId].sort()).toEqual([...room].sort());
    // Teams stay even when the team size has room to spare.
    expect(splitIntoTeams(room, 3, names).teams.map((team) => team.playerIds.length)).toEqual([2, 2]);
    // An even room has no floater.
    expect(splitIntoTeams([MIRA, THEO, KAYA, JUNO], 2, names).floaterPlayerId).toBeNull();
    // Beyond two full teams and a floater, the rest wait.
    const crowd = splitIntoTeams([MIRA, THEO, KAYA, JUNO, NOVA, SAM, 'u-extra'], 2, names);
    expect(crowd.teams.flatMap((team) => team.playerIds)).toHaveLength(4);
    expect(crowd.floaterPlayerId).not.toBeNull();
  });

  it('shuffles by the random source it is given, and the reducer takes the result', () => {
    const first = () => 0;
    const { teams, floaterPlayerId } = splitIntoTeams([MIRA, THEO, KAYA, JUNO, NOVA], 2, ['Ice', 'Amber'], first);
    // With random() always 0 the Fisher–Yates shuffle rotates the list by one.
    expect(teams).toEqual([
      { name: 'Ice', playerIds: [THEO, KAYA] },
      { name: 'Amber', playerIds: [JUNO, NOVA] },
    ]);
    expect(floaterPlayerId).toBe(MIRA);
    const next = hushleReducer(teamSetupState({ teams: false }), setTeamsAction(teams, floaterPlayerId));
    expect(next.teams.map((team) => team.playerIds)).toEqual([
      [THEO, KAYA],
      [JUNO, NOVA],
    ]);
    expect(next.floaterPlayerId).toBe(MIRA);
  });

  it('keeps the classic payload without a floater, and carries the floater when there is one', () => {
    const teams = draftOf(state.teams);
    expect(setTeamsAction(teams, null)).toEqual({ type: 'set-teams', teams });
    expect(Object.keys(setTeamsAction(teams, null))).toEqual(['type', 'teams']);
    expect(setTeamsAction(teams, NOVA)).toEqual({ type: 'set-teams', teams, floaterPlayerId: NOVA });
  });

  it('seats, unseats and removes', () => {
    expect(seatedIds(state)).toEqual(new Set([MIRA, THEO, KAYA, JUNO, NOVA]));
    const ice = state.teams[0]!;
    expect(withPlayerAdded(state.teams, ice.id, SAM)[0]).toEqual({ name: 'Ice', playerIds: [MIRA, THEO, SAM] });
    expect(withPlayerAdded(state.teams, ice.id, MIRA)[0]).toEqual({ name: 'Ice', playerIds: [MIRA, THEO] });
    expect(withPlayerRemoved(state.teams, THEO)[0]).toEqual({ name: 'Ice', playerIds: [MIRA] });
    expect(withoutTeam(state.teams, ice.id)).toEqual([{ name: 'Amber', playerIds: [KAYA, JUNO] }]);
    // Round-trips through the reducer without losing the floater.
    const next = hushleReducer(state, setTeamsAction(withPlayerRemoved(state.teams, THEO), state.floaterPlayerId));
    expect(next.teams.map((team) => team.playerIds)).toEqual([[MIRA], [KAYA, JUNO]]);
    expect(next.floaterPlayerId).toBe(NOVA);
  });
});

describe('"this turn" log', () => {
  /** Feed a sequence of reducer states to the log, as one viewer sees them. */
  const replay = (states: HushleState[], viewer: string): TurnLog => {
    let log = EMPTY_TURN_LOG;
    let prev = null as ReturnType<typeof project> | null;
    for (const state of states) {
      const view = project(state, viewer);
      log = advanceTurnLog(log, prev, view);
      prev = view;
    }
    return log;
  };

  // Pin each drawn card so words are predictable.
  const pinned = (state: HushleState, word: string): HushleState =>
    state.currentCard ? { ...state, currentCard: { ...state.currentCard, id: `pinned-${word}`, word } } : state;

  const turn = (): HushleState[] => {
    const s0 = playingState();
    const s1 = pinned(hushleReducer(s0, { type: 'correct-guess' }), 'umbrella');
    const s2 = pinned(hushleReducer(s1, { type: 'pass' }), 'pyramid');
    // Cap is 3 cards per turn: this bust ends the turn.
    const s3 = hushleReducer(s2, { type: 'bust-forbidden', bustedBy: JUNO, cardId: s2.currentCard!.id });
    return [s0, s1, s2, s3];
  };

  it('records each card and its outcome for a viewer who saw the cards', () => {
    const log = replay(turn(), MIRA);
    expect(log.entries.map((entry) => [entry.word, entry.outcome])).toEqual([
      [LIGHTHOUSE.word, 'correct'],
      ['umbrella', 'pass'],
      ['pyramid', 'penalty'],
    ]);
  });

  it('keeps the words from the explaining team, who never saw them', () => {
    const log = replay(turn(), THEO);
    expect(log.entries.map((entry) => [entry.word, entry.outcome])).toEqual([
      [null, 'correct'],
      [null, 'pass'],
      [null, 'penalty'],
    ]);
  });

  it('keeps the finished turn on show between turns, and clears it when the next turn starts', () => {
    const states = turn();
    const between = states[states.length - 1]!;
    expect(isTurnRunning(between)).toBe(false);
    expect(replay(states, KAYA).entries).toHaveLength(3);
    const next = hushleReducer(between, { type: 'end-turn' });
    expect(replay([...states, next], KAYA).entries).toEqual([]);
  });

  it('notes a card swapped without scoring', () => {
    const s0 = playingState();
    const s1 = hushleReducer(s0, { type: 'next-card' });
    expect(replay([s0, s1], KAYA).entries.map((entry) => entry.outcome)).toEqual(['next']);
  });

  it('starts empty when the panel opens mid-turn, and on a new game', () => {
    expect(replay([playingState()], KAYA).entries).toEqual([]);
    const played = turn();
    const fresh = hushleReducer(played[played.length - 1]!, startGameAction(setupFromSettings(played[0]!.settings), KAYA));
    expect(replay([...played, fresh], KAYA).entries).toEqual([]);
  });

  it('does not pin a word on events it saw only in a batch', () => {
    const s0 = playingState();
    const s2 = hushleReducer(hushleReducer(s0, { type: 'correct-guess' }), { type: 'correct-guess' });
    const log = replay([s0, s2], MIRA);
    expect(log.entries.map((entry) => [entry.word, entry.outcome])).toEqual([
      [null, 'correct'],
      [null, 'correct'],
    ]);
  });
});