d0681c5efe
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
A hint's MoveRecord words are upper-cased on decode (for the move-history view), whereas an EvalResult keeps the backend's lower case. Seeding the move preview from the hint verbatim flipped the score caption to upper case and nudged its height. Lower-case the words in previewFromHint so the caption reads the same as the evaluate path it replaces.
167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
BLANK,
|
|
cellOccupied,
|
|
isBlankSlot,
|
|
newPlacement,
|
|
place,
|
|
placementFromHint,
|
|
previewFromHint,
|
|
rackView,
|
|
recallAt,
|
|
recallIndex,
|
|
recallToSlot,
|
|
reorderIndices,
|
|
reset,
|
|
toSubmit,
|
|
} from './placement';
|
|
|
|
const rack = ['A', 'Q', BLANK, 'N', 'I', 'W', 'E'];
|
|
|
|
describe('placement state machine', () => {
|
|
it('places a tile and marks the rack slot used', () => {
|
|
const p = place(newPlacement(rack), 0, 7, 7);
|
|
expect(p.pending).toHaveLength(1);
|
|
expect(rackView(p)[0].used).toBe(true);
|
|
expect(rackView(p)[1].used).toBe(false);
|
|
});
|
|
|
|
it('rejects reusing a slot or an occupied cell', () => {
|
|
let p = place(newPlacement(rack), 0, 7, 7);
|
|
p = place(p, 0, 7, 8); // same slot -> no-op
|
|
expect(p.pending).toHaveLength(1);
|
|
p = place(p, 1, 7, 7); // occupied cell -> no-op
|
|
expect(p.pending).toHaveLength(1);
|
|
});
|
|
|
|
it('requires a letter for a blank slot', () => {
|
|
const noLetter = place(newPlacement(rack), 2, 7, 7);
|
|
expect(noLetter.pending).toHaveLength(0);
|
|
const withLetter = place(newPlacement(rack), 2, 7, 7, 'x');
|
|
expect(withLetter.pending[0]).toMatchObject({ letter: 'X', blank: true });
|
|
});
|
|
|
|
it('recalls a tile by cell', () => {
|
|
let p = place(newPlacement(rack), 0, 7, 7);
|
|
p = recallAt(p, 7, 7);
|
|
expect(p.pending).toHaveLength(0);
|
|
expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0);
|
|
});
|
|
|
|
it('builds a sorted submit payload and returns null when empty', () => {
|
|
let p = place(newPlacement(rack), 1, 7, 9);
|
|
p = place(p, 0, 7, 7);
|
|
const sub = toSubmit(p);
|
|
expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]);
|
|
expect(toSubmit(newPlacement(rack))).toBeNull();
|
|
});
|
|
|
|
it('recalls a tile by rack index and reports occupied cells / blank slots', () => {
|
|
let p = place(newPlacement(rack), 0, 7, 7);
|
|
p = place(p, 1, 7, 8);
|
|
expect(cellOccupied(p, 7, 7)).toBe(true);
|
|
expect(cellOccupied(p, 6, 6)).toBe(false);
|
|
p = recallIndex(p, 0);
|
|
expect(p.pending.map((t) => t.rackIndex)).toEqual([1]);
|
|
expect(isBlankSlot(newPlacement(rack), 2)).toBe(true); // '?' slot
|
|
expect(isBlankSlot(newPlacement(rack), 0)).toBe(false);
|
|
});
|
|
|
|
it('submits a single tile as a one-tile payload', () => {
|
|
const sub = toSubmit(place(newPlacement(rack), 0, 7, 7));
|
|
expect(sub?.tiles).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('placementFromHint', () => {
|
|
it('maps hint letters and blanks onto rack slots', () => {
|
|
const p = placementFromHint(
|
|
[
|
|
{ row: 7, col: 7, letter: 'C', blank: false },
|
|
{ row: 7, col: 8, letter: 'A', blank: false },
|
|
{ row: 7, col: 9, letter: 'B', blank: true },
|
|
],
|
|
['C', 'A', BLANK, 'T'],
|
|
);
|
|
expect(p.pending).toHaveLength(3);
|
|
expect(p.pending[0]).toMatchObject({ rackIndex: 0, letter: 'C', blank: false });
|
|
expect(p.pending[2]).toMatchObject({ rackIndex: 2, letter: 'B', blank: true });
|
|
});
|
|
|
|
it('falls back to a blank slot when the hint letter is not in the rack', () => {
|
|
const p = placementFromHint([{ row: 7, col: 7, letter: 'Z', blank: false }], ['A', BLANK]);
|
|
expect(p.pending).toHaveLength(1);
|
|
expect(p.pending[0]).toMatchObject({ rackIndex: 1, letter: 'Z', blank: true });
|
|
});
|
|
|
|
it('skips hint tiles once the rack is exhausted', () => {
|
|
const p = placementFromHint(
|
|
[
|
|
{ row: 7, col: 7, letter: 'A', blank: false },
|
|
{ row: 7, col: 8, letter: 'B', blank: false },
|
|
],
|
|
['A'],
|
|
);
|
|
expect(p.pending.map((t) => t.letter)).toEqual(['A']);
|
|
});
|
|
});
|
|
|
|
describe('previewFromHint', () => {
|
|
it('reuses the hint move as a legal preview, lower-casing words to match evaluate', () => {
|
|
const preview = previewFromHint({
|
|
player: 0,
|
|
action: 'play',
|
|
dir: 'V',
|
|
mainRow: 7,
|
|
mainCol: 7,
|
|
tiles: [{ row: 7, col: 7, letter: 'C', blank: false }],
|
|
words: ['CAT', 'AN'],
|
|
count: 0,
|
|
score: 18,
|
|
total: 42,
|
|
});
|
|
expect(preview).toEqual({ legal: true, score: 18, words: ['cat', 'an'], dir: 'V' });
|
|
});
|
|
});
|
|
|
|
describe('reorderIndices', () => {
|
|
it('lifts an element and drops it at the given slot among the others', () => {
|
|
expect(reorderIndices(4, 0, 2)).toEqual([1, 2, 0, 3]);
|
|
expect(reorderIndices(4, 3, 0)).toEqual([3, 0, 1, 2]);
|
|
expect(reorderIndices(4, 1, 1)).toEqual([0, 1, 2, 3]); // back to identity
|
|
expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end
|
|
});
|
|
});
|
|
|
|
describe('recallToSlot', () => {
|
|
it('reinserts the recalled tile at the chosen visible slot, reordering the rack', () => {
|
|
// Lift Q (slot 1) onto the board, then recall it to visible slot 4.
|
|
const p = place(newPlacement(rack), 1, 7, 7);
|
|
const { placement, order } = recallToSlot(p, 7, 7, 4);
|
|
expect(placement.pending).toHaveLength(0);
|
|
expect(placement.rack).toEqual(['A', BLANK, 'N', 'I', 'Q', 'W', 'E']);
|
|
expect(order).toEqual([0, 2, 3, 4, 1, 5, 6]);
|
|
});
|
|
|
|
it('counts only still-visible slots and remaps the remaining pending tiles', () => {
|
|
// A (slot 0) and N (slot 3) are on the board; recall A to visible slot 2 — the placed N is
|
|
// skipped, and N's rackIndex follows the rack permutation.
|
|
let p = place(newPlacement(rack), 0, 7, 7);
|
|
p = place(p, 3, 7, 8);
|
|
const { placement, order } = recallToSlot(p, 7, 7, 2);
|
|
expect(placement.rack).toEqual(['Q', BLANK, 'N', 'A', 'I', 'W', 'E']);
|
|
expect(order).toEqual([1, 2, 3, 0, 4, 5, 6]);
|
|
expect(placement.pending).toHaveLength(1);
|
|
expect(placement.pending[0].rackIndex).toBe(2);
|
|
expect(placement.rack[placement.pending[0].rackIndex]).toBe('N');
|
|
});
|
|
|
|
it('clamps the slot to the end and is an identity no-op when no tile sits at the cell', () => {
|
|
const p = place(newPlacement(rack), 1, 7, 7);
|
|
expect(recallToSlot(p, 7, 7, 99).placement.rack).toEqual(['A', BLANK, 'N', 'I', 'W', 'E', 'Q']);
|
|
const none = recallToSlot(p, 0, 0, 0);
|
|
expect(none.placement).toBe(p);
|
|
expect(none.order).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
|
});
|
|
});
|