Word Search: extract pure selection/match logic + pin with vitest

Per Codex's Phase 2 audit notes. Moved the drag-snap (lineFrom) and find-match
(matchWord) logic into $lib/wordsearch.js and added vitest coverage:
- lineFrom always yields a straight, in-bounds path — a non-straight drag snaps,
  never returns bent; single cell and edge-clamping covered.
- matchWord matches forward + reversed selections, is a harmless no-op on an
  already-found word (so completion/best-time can't double-record), and returns
  null for non-words / too-short selections.

Restore behaviour audited: finish() (which records best-time) only runs when the
final word is found mid-play; on refresh, restore() repopulates found cells +
time and the derived status flips to done WITHOUT calling finish(), so best-time
never re-records. First JS test runner for the frontend (npm test → vitest run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-10 20:23:13 -04:00
parent 90cd0291a3
commit 33d5d55c33
5 changed files with 443 additions and 28 deletions
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { lineFrom, matchWord } from './wordsearch.js';
const colinear = (cells) => {
if (cells.length < 2) return true;
const [r0, c0] = cells[0], [r1, c1] = cells[1];
const sr = Math.sign(r1 - r0), sc = Math.sign(c1 - c0);
return cells.every(([r, c], i) => r === r0 + sr * i && c === c0 + sc * i);
};
describe('lineFrom', () => {
it('builds straight horizontal / vertical / diagonal runs', () => {
expect(lineFrom([2, 1], [2, 4], 10)).toEqual([[2, 1], [2, 2], [2, 3], [2, 4]]);
expect(lineFrom([1, 3], [4, 3], 10)).toEqual([[1, 3], [2, 3], [3, 3], [4, 3]]);
expect(lineFrom([0, 0], [3, 3], 10)).toEqual([[0, 0], [1, 1], [2, 2], [3, 3]]);
});
it('snaps a non-straight drag onto a straight line (never bent)', () => {
// knight-ish move (dr=1, dc=3) → should snap, and stay colinear
const cells = lineFrom([5, 2], [6, 8], 10);
expect(colinear(cells)).toBe(true);
// a near-diagonal still resolves to a straight path
expect(colinear(lineFrom([0, 0], [4, 5], 10))).toBe(true);
});
it('clamps to the grid and handles a single cell', () => {
expect(lineFrom([0, 0], [0, 0], 10)).toEqual([[0, 0]]);
const cells = lineFrom([8, 8], [20, 8], 10); // runs off the bottom edge
expect(cells.every(([r, c]) => r >= 0 && r < 10 && c >= 0 && c < 10)).toBe(true);
});
});
describe('matchWord', () => {
const grid = ['CALM', 'XXXX', 'XXXX', 'XXXX']; // CALM across row 0
const words = ['CALM', 'PEACE'];
it('matches a forward word', () => {
expect(matchWord([[0, 0], [0, 1], [0, 2], [0, 3]], grid, words, [])).toBe('CALM');
});
it('matches a reversed selection to the listed word', () => {
expect(matchWord([[0, 3], [0, 2], [0, 1], [0, 0]], grid, words, [])).toBe('CALM');
});
it('is harmless on an already-found word (no re-record)', () => {
expect(matchWord([[0, 0], [0, 1], [0, 2], [0, 3]], grid, words, ['CALM'])).toBe(null);
});
it('returns null for a non-word or too-short selection', () => {
expect(matchWord([[1, 0], [1, 1], [1, 2], [1, 3]], grid, words, [])).toBe(null); // XXXX
expect(matchWord([[0, 0]], grid, words, [])).toBe(null);
expect(matchWord([], grid, words, [])).toBe(null);
});
});