Files
scrabble-game/ui/src/lib/placement.test.ts
T
Ilia Denisov 92f48a3b12
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
Backend infers play direction; UI previews words and gates submit on legality
A single tile that only extended a word perpendicular to the client-declared
direction was rejected: the UI always sent dir=H for one-tile plays (the
dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing
"А" above "БАК" to form "АБАК" failed the solver's main-word-length check even
though the word is in the dictionary.

Make the backend infer a play's orientation from the placed tiles and the board
(internal/engine.resolveDirection): two or more tiles by the line they share, a
lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction
becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests
and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V"
(SubmitPlayDir) so a rebuilt game matches the one committed.

UI: stop computing/sending direction; the preview now shows the words a move
forms with its total score (game.previewWords); the make-move control is disabled
until the play is confirmed legal; the "your turn" label hides while tiles are
pending. Delete the orphaned Controls.svelte.

Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode
and the loadtest edge client to the new contract. Bake the decision into
ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README.
2026-06-11 22:42:33 +02:00

115 lines
3.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
BLANK,
cellOccupied,
isBlankSlot,
newPlacement,
place,
placementFromHint,
rackView,
recallAt,
recallIndex,
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('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
});
});