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:
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { lineFrom, matchWord } from '$lib/wordsearch.js';
|
||||
|
||||
let { onstatus } = $props();
|
||||
|
||||
@@ -69,25 +70,6 @@
|
||||
return [r, c];
|
||||
}
|
||||
|
||||
// Snap a drag to the nearest of the 8 straight directions.
|
||||
function lineFrom(start, end) {
|
||||
const [r0, c0] = start, [r1, c1] = end;
|
||||
let dr = r1 - r0, dc = c1 - c0;
|
||||
if (dr === 0 && dc === 0) return [start];
|
||||
const adr = Math.abs(dr), adc = Math.abs(dc);
|
||||
if (adr > adc * 2) dc = 0;
|
||||
else if (adc > adr * 2) dr = 0;
|
||||
else { const m = Math.max(adr, adc); dr = Math.sign(dr) * m; dc = Math.sign(dc) * m; }
|
||||
const steps = Math.max(Math.abs(dr), Math.abs(dc));
|
||||
const sr = Math.sign(dr), sc = Math.sign(dc), cells = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const r = r0 + sr * i, c = c0 + sc * i;
|
||||
if (r < 0 || r >= size || c < 0 || c >= size) break;
|
||||
cells.push([r, c]);
|
||||
}
|
||||
return cells.length ? cells : [start];
|
||||
}
|
||||
|
||||
function down(e) {
|
||||
if (status === 'done') return;
|
||||
selecting = true;
|
||||
@@ -98,7 +80,7 @@
|
||||
}
|
||||
function move(e) {
|
||||
if (!selecting) return;
|
||||
sel = lineFrom(sel[0], cellAt(e));
|
||||
sel = lineFrom(sel[0], cellAt(e), size);
|
||||
}
|
||||
function up() {
|
||||
if (!selecting) return;
|
||||
@@ -108,11 +90,7 @@
|
||||
}
|
||||
|
||||
function evaluate(cells) {
|
||||
if (cells.length < 2) return;
|
||||
const word = cells.map(([r, c]) => grid[r][c]).join('');
|
||||
const rev = [...word].reverse().join('');
|
||||
const hit = words.includes(word) && !found.includes(word) ? word
|
||||
: words.includes(rev) && !found.includes(rev) ? rev : null;
|
||||
const hit = matchWord(cells, grid, words, found);
|
||||
if (!hit) return;
|
||||
found = [...found, hit];
|
||||
const fc = new Set(foundCells);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Pure word-search helpers — no DOM, so they're unit-testable. Used by
|
||||
// WordSearchGame.svelte for drag selection and find-matching.
|
||||
|
||||
/**
|
||||
* Snap a drag from `start` to `end` onto the nearest of the 8 straight
|
||||
* directions and return the colinear cells, clamped to the grid. Guarantees a
|
||||
* straight path — a non-straight drag is snapped, never returned bent.
|
||||
* @param {[number,number]} start
|
||||
* @param {[number,number]} end
|
||||
* @param {number} size
|
||||
* @returns {[number,number][]}
|
||||
*/
|
||||
export function lineFrom(start, end, size) {
|
||||
const [r0, c0] = start, [r1, c1] = end;
|
||||
let dr = r1 - r0, dc = c1 - c0;
|
||||
if (dr === 0 && dc === 0) return [start];
|
||||
const adr = Math.abs(dr), adc = Math.abs(dc);
|
||||
if (adr > adc * 2) dc = 0; // mostly vertical → snap orthogonal
|
||||
else if (adc > adr * 2) dr = 0; // mostly horizontal → snap orthogonal
|
||||
else { const m = Math.max(adr, adc); dr = Math.sign(dr) * m; dc = Math.sign(dc) * m; } // diagonal
|
||||
const steps = Math.max(Math.abs(dr), Math.abs(dc));
|
||||
const sr = Math.sign(dr), sc = Math.sign(dc), cells = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const r = r0 + sr * i, c = c0 + sc * i;
|
||||
if (r < 0 || r >= size || c < 0 || c >= size) break;
|
||||
cells.push([r, c]);
|
||||
}
|
||||
return cells.length ? cells : [start];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the letters along `cells` off `grid` (array of row strings) and return
|
||||
* the matching not-yet-found word (forward or reversed), or null. Re-selecting
|
||||
* an already-found word, or a non-word, returns null — harmless.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function matchWord(cells, grid, words, found) {
|
||||
if (!cells || cells.length < 2) return null;
|
||||
const word = cells.map(([r, c]) => grid[r][c]).join('');
|
||||
const rev = [...word].reverse().join('');
|
||||
if (words.includes(word) && !found.includes(word)) return word;
|
||||
if (words.includes(rev) && !found.includes(rev)) return rev;
|
||||
return null;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user