Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The GADDAG itself lives outside this repo, in [`@kamilmielnik/gaddag`](https://g
### App package (`@scrabble-solver/scrabble-solver`)

- **Routing**: Next.js Pages Router (`src/pages/`). API routes: `solve`, `verify`, `visit`, `dictionary/[locale]` (binary GADDAG download), and `dictionary/[locale]/[word]` (definitions). The path alias `@/*` resolves to `src/*` (set in `tsconfig.json`).
- **State**: Redux Toolkit + Redux-Saga. Slices in `src/state/{app,board,cellFilters,dictionary,hoveredTile,i18n,rack,results,settings,solve,verify}`, each exporting `<name>Slice` (reducer + actions) and selectors. The root saga in `state/sagas.ts` reacts to slice actions: `submit` → call SDK → write results back. `solve`, `verify`, and `dictionary` use `takeLatest` (only the latest in-flight request resolves); cell/rack edits use `takeEvery`. State is intentionally **not** serializable-checked (`serializableCheck: false`) because slices hold class instances (`Board`, `Tile`). `initialize({ version })` carries the app version (from `getStaticProps`) into the `app` slice; the translations cache is keyed on it.
- **State**: Redux Toolkit + Redux-Saga. Slices in `src/state/{app,board,cellFilters,dictionary,hoveredTile,hoveredWord,i18n,rack,results,settings,solve,verify}`, each exporting `<name>Slice` (reducer + actions) and selectors. The root saga in `state/sagas.ts` reacts to slice actions: `submit` → call SDK → write results back. `solve`, `verify`, and `dictionary` use `takeLatest` (only the latest in-flight request resolves); cell/rack edits use `takeEvery`. State is intentionally **not** serializable-checked (`serializableCheck: false`) because slices hold class instances (`Board`, `Tile`). `initialize({ version })` carries the app version (from `getStaticProps`) into the `app` slice; the translations cache is keyed on it.
- **Board render budget**: `Cell`/`Tile` render 225+ times at once and must not subscribe to the store — every value they need flows down from `BoardPure` as memo-friendly props, and event handlers read state at event time via `useTypedStore().getState()` (see `Cell.tsx`). Anything added to a cell that subscribes via `useSelector` multiplies by 225 and shows up directly in TBT.
- **SDK layer (`src/sdk/`)**: thin browser/server clients for the four API routes. `findWordDefinitions` is memoized at the saga level via `lib/memoize`. Always go through SDK — never `fetch` directly from a saga or component.
- **Persistence**: settings, board, and rack auto-persist to `localStorage` via `store2` under the `scrabble-solver` namespace. The `useLocalStorage` hook (mounted in `pages/index.tsx`) subscribes to the three slices and writes them out on every change (skipping each effect's first post-hydration run, which would only echo what was just read) — **adding a new field to `SettingsState` is enough; you don't need to touch any save code** (PR #321, Apr 2026). Boot state is deterministic (SSR/hydration must match — issue #412): persisted settings/board/rack are applied post-mount by `hydratePersistedState` in the `initialize()` saga, guarded so a throw leaves the deterministic defaults and `app.hydrated` still fires (`finally`) — otherwise persistence would silently die for the session. The `state/localStorage.ts` getters treat corrupt/mismatched entries as absent and remove them; the saga skips no-op `init` dispatches so an empty-storage boot changes no state identities and re-renders nothing. The active locale's translations are also cached (`translations` key, keyed by app version + locale) and hydrated synchronously so a returning user's first post-hydration paint is already translated. When changing settings shape, add a migration block (see `migrateLegacySettings` for the pattern — dated comment with introduction date and life expectancy).
Expand Down
4 changes: 2 additions & 2 deletions e2e/bugs/398-digraphs-in-created-words.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ test.describe('#398 - Solitary digraph tiles are recognised as created words', (
const modal = Lib.getOpenModal(page);
await expect(modal.getByText('ch', { exact: true })).toHaveCount(0);
await expect(modal.getByText('chiclean', { exact: true })).toBeVisible();
await expect(modal.getByLabel('Incorrecto', { exact: true }).getByText('0', { exact: true })).toBeVisible();
await expect(modal.getByLabel('Correcto', { exact: true }).getByText('1', { exact: true })).toBeVisible();
await expect(modal.getByLabel('Incorrecto', { exact: true })).toHaveCount(0);
await expect(modal.getByLabel('Correcto', { exact: true })).toHaveCount(1);
});
});
33 changes: 33 additions & 0 deletions e2e/bugs/stale-translations-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';

import * as Lib from '../lib';

/*
* A translations cache written by a build that knew fewer translation keys
* crashed translate() on the keys added since.
*/
test.describe('Stale translations cache', () => {
test('recovers when the cached translations are missing a newly added key', async ({ page }) => {
await Lib.visitIndex(page);
await page.keyboard.press('Shift');
await page.waitForFunction(() => {
return Object.keys(localStorage).some((storageKey) => storageKey.includes('translations'));
});

await page.evaluate(() => {
const key = Object.keys(localStorage).find((storageKey) => storageKey.includes('translations'));

if (!key) {
throw new Error('Translations cache not found');
}

const stored = JSON.parse(localStorage.getItem(key) as string) as { translations: Record<string, string> };
delete stored.translations['words.validity'];
localStorage.setItem(key, JSON.stringify(stored));
});
await page.reload();

await page.getByLabel('Created words', { exact: true }).click();
await expect(Lib.getOpenModal(page).getByRole('button', { name: 'Validity', exact: true })).toBeVisible();
});
});
72 changes: 72 additions & 0 deletions e2e/features/427-highlight-created-words.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { expect, type Page, test } from '@playwright/test';

import * as Lib from '../lib';

/*
* @see https://github.com/kamilmielnik/scrabble-solver/issues/427
*/
test.describe('#427 - Highlight created words on board on hover', () => {
test('highlights the hovered word only at its own position', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await Lib.typeBoard(page, 'cat', 'vertical', { x: 8, y: 8 });
await openWordsModal(page);

await Lib.hoverWord(page, 3, 3, 'horizontal');
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 3, 3));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 4, 3));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 5, 3));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 8, 8));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 8, 9));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 8, 10));

await Lib.hoverWord(page, 8, 8, 'vertical');
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 3, 3));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 4, 3));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 5, 3));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 8, 8));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 8, 9));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 8, 10));

await Lib.moveMouseAway(page);
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 3, 3));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 4, 3));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 5, 3));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 8, 8));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 8, 9));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 8, 10));
});

test('highlights a hovered invalid word', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'zvq', 'horizontal', { x: 5, y: 5 });
await openWordsModal(page);

await expect(page.getByTestId('word-5-5-horizontal').getByLabel('Invalid', { exact: true })).toBeVisible();
await Lib.hoverWord(page, 5, 5, 'horizontal');
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 5, 5));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 6, 5));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 7, 5));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 8, 5));
});

test('clears the highlight when the modal closes', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await openWordsModal(page);

await Lib.hoverWord(page, 3, 3, 'horizontal');
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 3, 3));

await page.keyboard.press('Escape');
await expect(Lib.getOpenModal(page)).toHaveCount(0);
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 3, 3));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 4, 3));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 5, 3));
});
});

async function openWordsModal(page: Page) {
await page.getByLabel('Created words', { exact: true }).click();
await expect(Lib.getOpenModal(page)).toBeVisible();
}
45 changes: 45 additions & 0 deletions e2e/features/words-dictionary-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, type Page, test } from '@playwright/test';

import * as Lib from '../lib';

test.describe('Words dictionary search', () => {
test('searches the hovered word and its collisions in the dictionary', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await Lib.typeBoard(page, 'od', 'vertical', { x: 3, y: 4 });
await Lib.typeBoard(page, 'dog', 'horizontal', { x: 8, y: 8 });
await openWordsModal(page);

await Lib.hoverWord(page, 3, 3, 'horizontal');
await expect(Lib.getDictionaryInput(page)).toHaveValue('cat, cod');

await Lib.hoverWord(page, 3, 3, 'vertical');
await expect(Lib.getDictionaryInput(page)).toHaveValue('cod, cat');

await Lib.hoverWord(page, 8, 8, 'horizontal');
await expect(Lib.getDictionaryInput(page)).toHaveValue('dog');

await expect(getModalDictionary(page)).toBeHidden();
});

test.describe('mobile', () => {
test.use({ viewport: { width: 800, height: 900 } });

test('shows the dictionary below the words table', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await openWordsModal(page);

await expect(getModalDictionary(page)).toBeVisible();
});
});
});

function getModalDictionary(page: Page) {
return Lib.getOpenModal(page).locator('[class*="dictionary"]');
}

async function openWordsModal(page: Page) {
await page.getByLabel('Created words', { exact: true }).click();
await expect(Lib.getOpenModal(page)).toBeVisible();
}
77 changes: 77 additions & 0 deletions e2e/features/words-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, type Page, test } from '@playwright/test';

import * as Lib from '../lib';

test.describe('Words preview', () => {
test.use({ viewport: { width: 800, height: 900 } });

test('selects the first word on open and previews the selected word on the board', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await Lib.typeBoard(page, 'dog', 'horizontal', { x: 3, y: 5 });
await openWordsModal(page);

await expect(Lib.getWord(page, 0)).toHaveAttribute('aria-current', 'true');

await Lib.getWord(page, 1).click();
await expect(Lib.getWord(page, 1)).toHaveAttribute('aria-current', 'true');
await expect(Lib.getWord(page, 0)).not.toHaveAttribute('aria-current');

await Lib.getOpenModal(page).getByRole('button', { name: 'Preview', exact: true }).click();
await expect(Lib.getOpenModal(page)).toHaveCount(0);
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 3, 5));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 4, 5));
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 5, 5));
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 3, 3));
});

test('highlights either the result candidate or the selected word, never both', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await Lib.typeRack(page, 's');
await Lib.solve(page);

await openWordsModal(page);
await Lib.getOpenModal(page).getByRole('button', { name: 'Preview', exact: true }).click();
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 3, 3));

await page.getByRole('button', { name: 'Results', exact: true }).click();
const result = Lib.getOpenModal(page).getByTestId('result').first();
await expect(result).not.toHaveAttribute('aria-current');
await result.click();
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 3, 3));
});

test('clears the highlights when the layout breakpoint changes', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await openWordsModal(page);
await Lib.getOpenModal(page).getByRole('button', { name: 'Preview', exact: true }).click();
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 3, 3));

await page.setViewportSize({ width: 1280, height: 900 });
await Lib.expectTileNotHighlighted(Lib.getBoardTile(page, 3, 3));
});

test.describe('phone', () => {
test.use({ viewport: { width: 420, height: 900 } });

test('previewing from the menu-opened modal returns to the board', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await page.getByRole('button', { name: 'Menu', exact: true }).click();
await Lib.getOpenModal(page).getByLabel('Created words', { exact: true }).click();

await expect(Lib.getWord(page, 0)).toHaveAttribute('aria-current', 'true');

await page.getByRole('button', { name: 'Preview', exact: true }).click();
await expect(Lib.getModal(page)).toHaveCount(0);
await Lib.expectTileHighlighted(Lib.getBoardTile(page, 3, 3));
});
});
});

async function openWordsModal(page: Page) {
await page.getByLabel('Created words', { exact: true }).click();
await expect(Lib.getOpenModal(page)).toBeVisible();
}
27 changes: 27 additions & 0 deletions e2e/features/words-table-filtering.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, type Page, test } from '@playwright/test';

import * as Lib from '../lib';

test.describe('Words table filtering', () => {
test('groups matching words first and dims the rest', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await Lib.typeBoard(page, 'dog', 'horizontal', { x: 3, y: 5 });
await openWordsModal(page);

await Lib.assertWord(page, 0, 'cat');
await Lib.assertWord(page, 1, 'dog');

await Lib.getWordsFilterInput(page).pressSequentially('d');

await Lib.assertWord(page, 0, 'dog');
await Lib.assertWord(page, 1, 'cat');
await expect(Lib.getWord(page, 0)).not.toHaveAttribute('aria-hidden');
await expect(Lib.getWord(page, 1)).toHaveAttribute('aria-hidden', 'true');
});
});

async function openWordsModal(page: Page) {
await page.getByLabel('Created words', { exact: true }).click();
await expect(Lib.getOpenModal(page)).toBeVisible();
}
38 changes: 38 additions & 0 deletions e2e/features/words-table-sorting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, type Page, test } from '@playwright/test';

import * as Lib from '../lib';

test.describe('Words table sorting', () => {
test('sorts by word, validity, and coordinates with the results-table sort behavior', async ({ page }) => {
await Lib.visitIndex(page);
await Lib.typeBoard(page, 'cat', 'horizontal', { x: 3, y: 3 });
await Lib.typeBoard(page, 'zvq', 'horizontal', { x: 3, y: 5 });
await openWordsModal(page);
const modal = Lib.getOpenModal(page);

await Lib.assertWord(page, 0, 'cat');
await Lib.assertWord(page, 1, 'zvq');
await expect(Lib.getWord(page, 0)).toContainText('4D');

await modal.getByRole('button', { name: 'Word', exact: true }).click();
await Lib.assertWord(page, 0, 'zvq');
await Lib.assertWord(page, 1, 'cat');

await modal.getByRole('button', { name: 'Validity', exact: true }).click();
await Lib.assertWord(page, 0, 'cat');
await Lib.assertWord(page, 1, 'zvq');

await modal.getByRole('button', { name: 'Validity', exact: true }).click();
await Lib.assertWord(page, 0, 'zvq');
await Lib.assertWord(page, 1, 'cat');

await modal.getByRole('button', { name: 'Coordinates', exact: true }).click();
await Lib.assertWord(page, 0, 'cat');
await Lib.assertWord(page, 1, 'zvq');
});
});

async function openWordsModal(page: Page) {
await page.getByLabel('Created words', { exact: true }).click();
await expect(Lib.getOpenModal(page)).toBeVisible();
}
15 changes: 15 additions & 0 deletions e2e/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getRackTile,
getResult,
getResultsContainer,
getWord,
} from './selectors';

interface BoardPosition {
Expand Down Expand Up @@ -89,6 +90,20 @@ export async function hoverResult(page: Page, index = 0): Promise<void> {
await getResult(page, index).hover();
}

/**
* react-window remounts rows during its initial measure pass, and a row
* replaced under an already-hovered cursor never receives a new mouseenter -
* let the list settle before hovering.
*/
export async function hoverWord(page: Page, x: number, y: number, direction: Direction): Promise<void> {
await page.waitForTimeout(100);
await page.getByTestId(`word-${x}-${y}-${direction}`).hover();
}

export async function assertWord(page: Page, index: number, word: string): Promise<void> {
await expect(getWord(page, index)).toHaveAttribute('aria-label', word);
}

export async function assertResult(page: Page, index: number, word: string, points: number): Promise<void> {
const result = getResult(page, index);

Expand Down
19 changes: 18 additions & 1 deletion e2e/lib/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export function getDictionary(page: Page): Locator {
}

export function getDictionaryInput(page: Page): Locator {
return getDictionary(page).getByRole('textbox');
// includeHidden: true so that the dictionary can be asserted on while modal is opened
return getDictionary(page).getByRole('textbox', { includeHidden: true });
}

export function getDictionaryTitles(page: Page): Locator {
Expand Down Expand Up @@ -58,6 +59,22 @@ export function getResult(page: Page, index = 0): Locator {
return getResults(page).nth(index);
}

export function getWordsContainer(page: Page): Locator {
return page.getByTestId('words');
}

export function getWords(page: Page): Locator {
return getWordsContainer(page).locator('[data-testid^="word-"]');
}

export function getWord(page: Page, index = 0): Locator {
return getWords(page).nth(index);
}

export function getWordsFilterInput(page: Page): Locator {
return getWordsContainer(page).getByRole('textbox');
}

export function getSettingsButton(page: Page): Locator {
return page.getByTestId('settings-button');
}
Expand Down
Loading