Skip to content

Commit 763a415

Browse files
committed
Add deterministic Rift and restore-state Playwright tests
1 parent 6c72a83 commit 763a415

9 files changed

Lines changed: 340 additions & 4 deletions

File tree

.github/workflows/ui-tests.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: ui-tests
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
playwright:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: Checkout
12+
uses: actions/checkout@v4
13+
14+
- name: Setup Node
15+
uses: actions/setup-node@v4
16+
with:
17+
node-version: 20
18+
cache: npm
19+
20+
- name: Install dependencies
21+
run: npm install --no-package-lock
22+
23+
- name: Install Playwright browsers
24+
run: npx playwright install --with-deps chromium
25+
26+
- name: Run UI tests
27+
run: npm run test:ui
28+
29+
- name: Upload Playwright artifacts on failure
30+
if: failure()
31+
uses: actions/upload-artifact@v4
32+
with:
33+
name: playwright-artifacts
34+
path: |
35+
playwright-report/
36+
test-results/
37+
if-no-files-found: ignore

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
node_modules/
22
package-lock.json
3+
playwright-report/
4+
test-results/

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@
66
"type": "module",
77
"scripts": {
88
"lint": "eslint script.js sw.js",
9-
"lint:fix": "eslint --fix script.js sw.js"
9+
"lint:fix": "eslint --fix script.js sw.js",
10+
"test:serve": "http-server . -p 4173 -c-1 --silent",
11+
"test:ui": "playwright test",
12+
"test:ui:headed": "playwright test --headed"
1013
},
1114
"devDependencies": {
15+
"@playwright/test": "^1.54.2",
1216
"eslint": "^9.0.0",
13-
"globals": "^15.0.0"
17+
"globals": "^15.0.0",
18+
"http-server": "^14.1.1"
1419
}
1520
}

playwright.config.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
const PORT = 4173;
4+
const BASE_URL = `http://127.0.0.1:${PORT}`;
5+
6+
export default defineConfig({
7+
testDir: './tests',
8+
timeout: 45_000,
9+
expect: {
10+
timeout: 10_000
11+
},
12+
fullyParallel: true,
13+
forbidOnly: !!process.env.CI,
14+
retries: process.env.CI ? 1 : 0,
15+
reporter: [['list'], ['html', { open: 'never' }]],
16+
use: {
17+
baseURL: BASE_URL,
18+
trace: 'on-first-retry',
19+
screenshot: 'only-on-failure',
20+
video: 'retain-on-failure'
21+
},
22+
webServer: {
23+
command: 'npm run test:serve',
24+
url: BASE_URL,
25+
reuseExistingServer: !process.env.CI,
26+
timeout: 30_000
27+
},
28+
projects: [
29+
{
30+
name: 'chromium',
31+
use: { ...devices['Desktop Chrome'] }
32+
},
33+
{
34+
name: 'mobile-chrome',
35+
use: { ...devices['Pixel 7'] }
36+
}
37+
]
38+
});

script.js

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
const RIFT_STATUS_DELAY_REDUCED_MOTION_MS = 500;
1010
const RIFT_EVALUATE_DEBOUNCE_MS = 140;
1111
const RIFT_NON_CONFLICT_CHECK_INTERVAL = 5;
12+
const TEST_MODE = window.location.search.includes('e2e=1');
1213

1314
const boardEl = document.getElementById('board');
1415
const statusEl = document.getElementById('status');
@@ -255,7 +256,7 @@
255256

256257
// ── UI updates ────────────────────────────────────────────────────────────
257258

258-
const MIN_SPLASH_MS=800;
259+
const MIN_SPLASH_MS=TEST_MODE?0:800;
259260
const splashShownAt=Date.now();
260261
function hideSplash(){
261262
const splash=document.getElementById('splash');
@@ -833,6 +834,90 @@
833834
saveGame();
834835
}
835836

837+
function normalizeTestBoard(board, fallbackValue=0){
838+
if(!Array.isArray(board)||board.length!==GRID_SIZE){
839+
return Array.from({length:GRID_SIZE},()=>Array.from({length:GRID_SIZE},()=>fallbackValue));
840+
}
841+
return board.map(row=>{
842+
if(!Array.isArray(row)||row.length!==GRID_SIZE) return Array.from({length:GRID_SIZE},()=>fallbackValue);
843+
return row.map(value=>{
844+
const n=Number(value);
845+
if(Number.isInteger(n)&&n>=0&&n<=9) return n;
846+
return fallbackValue;
847+
});
848+
});
849+
}
850+
851+
function applyTestBoardState(payload={}){
852+
const nextGrid=normalizeTestBoard(payload.grid,0);
853+
const nextStartingGrid=normalizeTestBoard(payload.startingGrid,0);
854+
grid=cloneGrid(nextGrid);
855+
startingGrid=cloneGrid(nextStartingGrid);
856+
notes=Array.from({length:GRID_SIZE},()=>Array.from({length:GRID_SIZE},()=>new Set()));
857+
selected=(payload.selected&&Number.isInteger(payload.selected.r)&&Number.isInteger(payload.selected.c))
858+
?{r:Math.max(0,Math.min(8,payload.selected.r)),c:Math.max(0,Math.min(8,payload.selected.c))}
859+
:null;
860+
elapsed=normalizeElapsed(payload.elapsed);
861+
notesMode=!!payload.notesMode;
862+
autoCleanup=payload.autoCleanup!==false;
863+
history=[]; future=[];
864+
boardShellEl.classList.remove('victory-glow');
865+
clearRiftVisualState();
866+
riftState={active:false,sequenceRunning:false,nodes:[],hasTriggered:false,cooldownUntil:0,copyKey:'pattern'};
867+
movesSinceSolvabilityCheck=0;
868+
captureLastSolvableSnapshot();
869+
render();
870+
saveGame();
871+
}
872+
873+
function exposeTestApi(){
874+
window.__shandokuTest={
875+
resetStorage(){
876+
localStorage.removeItem(STORAGE_KEY);
877+
},
878+
setBoardState(payload){
879+
applyTestBoardState(payload);
880+
},
881+
getState(){
882+
return {
883+
grid:cloneGrid(grid),
884+
startingGrid:cloneGrid(startingGrid),
885+
selected:selected?{...selected}:null,
886+
notesMode,
887+
autoCleanup,
888+
errorCount:countErrors(),
889+
status:statusEl.textContent
890+
};
891+
},
892+
selectCell(r,c){
893+
selected={r:Math.max(0,Math.min(8,r)),c:Math.max(0,Math.min(8,c))};
894+
render();
895+
saveGame();
896+
},
897+
placeNumber(n){
898+
placeNumber(n);
899+
},
900+
newGame(){
901+
newGame();
902+
},
903+
forceRift(payload={}){
904+
const r=Number.isInteger(payload.r)?Math.max(0,Math.min(8,payload.r)):4;
905+
const c=Number.isInteger(payload.c)?Math.max(0,Math.min(8,payload.c)):4;
906+
clearRiftVisualState();
907+
riftState.active=true;
908+
riftState.sequenceRunning=false;
909+
riftState.nodes=[{r,c}];
910+
riftState.hasTriggered=true;
911+
riftState.cooldownUntil=Date.now()+RIFT_COOLDOWN_MS;
912+
boardShellEl.classList.add('rift-active');
913+
statusEl.classList.add('rift-status');
914+
render();
915+
setStatus('Rift node found. Tap the marked cell.');
916+
saveGame();
917+
}
918+
};
919+
}
920+
836921
// ── Build digit pad ───────────────────────────────────────────────────────
837922

838923
function buildDigitPad(){
@@ -949,6 +1034,7 @@
9491034

9501035
applyTheme(localStorage.getItem(THEME_KEY)||'dark');
9511036
buildDigitPad();
1037+
if(TEST_MODE) exposeTestApi();
9521038

9531039
// Defer game init until after the first paint so the splash animates.
9541040
requestAnimationFrame(()=>requestAnimationFrame(()=>{

sw.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const CACHE_NAME = 'shandoku-wife-edition-v5';
1+
const CACHE_NAME = 'shandoku-wife-edition-v6';
22
const STATIC_ASSETS = [
33
'./index.html',
44
'./style.css',

tests/gameplay.placeholder.spec.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Gameplay regression placeholders', () => {
4+
test('deterministic Rift trigger is visible', async ({ page }) => {
5+
await page.addInitScript(() => localStorage.clear());
6+
await page.goto('/?e2e=1');
7+
8+
await expect(page.locator('#board .cell')).toHaveCount(81);
9+
await page.evaluate(() => {
10+
window.__shandokuTest.setBoardState({
11+
grid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)),
12+
startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0))
13+
});
14+
window.__shandokuTest.forceRift({ r: 0, c: 0 });
15+
});
16+
17+
await expect(page.locator('.board-shell')).toHaveClass(/rift-active/);
18+
await expect(page.locator('.cell.rift-node[data-r="0"][data-c="0"]')).toBeVisible();
19+
await expect(page.locator('#status')).toContainText('Rift node found');
20+
});
21+
22+
test('restore last solvable state from Rift modal', async ({ page }) => {
23+
await page.addInitScript(() => localStorage.clear());
24+
await page.goto('/?e2e=1');
25+
await expect(page.locator('#board .cell')).toHaveCount(81);
26+
27+
await page.evaluate(() => {
28+
const blank = Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0));
29+
blank[0][0] = 4;
30+
window.__shandokuTest.setBoardState({
31+
grid: blank,
32+
startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)),
33+
selected: { r: 0, c: 1 }
34+
});
35+
});
36+
await page.getByRole('button', { name: 'Enter 4' }).click();
37+
await expect(page.locator('.cell[data-r="0"][data-c="1"]')).toHaveText('4');
38+
await expect(page.locator('.cell[data-r="0"][data-c="1"]')).toHaveClass(/error/);
39+
40+
await page.evaluate(() => window.__shandokuTest.forceRift({ r: 0, c: 1 }));
41+
await page.locator('.cell.rift-node[data-r="0"][data-c="1"]').click();
42+
await expect(page.locator('#riftModal')).toBeVisible();
43+
await page.getByRole('button', { name: 'Restore last solvable' }).click();
44+
45+
await expect(page.locator('.cell[data-r="0"][data-c="1"]')).toHaveText('');
46+
await expect(page.locator('.board-shell')).not.toHaveClass(/rift-active/);
47+
await expect(page.locator('#status')).toContainText('Restored to the last solvable state');
48+
});
49+
});

tests/ui.mobile.spec.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Mobile viewport behavior', () => {
4+
test('board renders and remains interactive on mobile profile', async ({ page, isMobile }) => {
5+
test.skip(!isMobile, 'This test is intended for mobile projects.');
6+
7+
await page.addInitScript(() => localStorage.clear());
8+
await page.goto('/?e2e=1');
9+
10+
await expect(page.locator('#board .cell')).toHaveCount(81);
11+
await expect(page.locator('.board-shell')).toBeVisible();
12+
13+
const touchCell = page.locator('.cell:not(.fixed)').first();
14+
await touchCell.tap();
15+
await expect(touchCell).toHaveClass(/selected/);
16+
});
17+
});

tests/ui.smoke.spec.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
async function openFreshGame(page) {
4+
await page.addInitScript(() => {
5+
localStorage.clear();
6+
sessionStorage.clear();
7+
});
8+
await page.goto('/?e2e=1');
9+
await expect(page.locator('#board .cell')).toHaveCount(81);
10+
}
11+
12+
test.describe('Shandoku smoke + core interactions', () => {
13+
test('app loads and board renders 81 cells', async ({ page }) => {
14+
await openFreshGame(page);
15+
16+
await expect(page.locator('h1')).toContainText('Shandoku');
17+
await expect(page.locator('#status')).toContainText('Tap a cell');
18+
});
19+
20+
test('cell selection works', async ({ page }) => {
21+
await openFreshGame(page);
22+
23+
const targetCell = page.locator('.cell:not(.fixed)').first();
24+
await targetCell.click();
25+
await expect(targetCell).toHaveClass(/selected/);
26+
});
27+
28+
test('entering a number updates the cell', async ({ page }) => {
29+
await openFreshGame(page);
30+
31+
await page.evaluate(() => {
32+
window.__shandokuTest.setBoardState({
33+
grid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)),
34+
startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)),
35+
selected: { r: 0, c: 0 }
36+
});
37+
});
38+
39+
await page.getByRole('button', { name: 'Enter 5' }).click();
40+
41+
const editedCell = page.locator('.cell[data-r="0"][data-c="0"]');
42+
await expect(editedCell).toHaveText('5');
43+
await expect(editedCell).toHaveClass(/user/);
44+
});
45+
46+
test('direct conflict is visibly indicated', async ({ page }) => {
47+
await openFreshGame(page);
48+
49+
await page.evaluate(() => {
50+
const emptyBoard = Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0));
51+
emptyBoard[0][0] = 1;
52+
window.__shandokuTest.setBoardState({
53+
grid: emptyBoard,
54+
startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)),
55+
selected: { r: 0, c: 1 }
56+
});
57+
});
58+
59+
await page.getByRole('button', { name: 'Enter 1' }).click();
60+
61+
const conflictCell = page.locator('.cell[data-r="0"][data-c="1"]');
62+
await expect(conflictCell).toHaveClass(/error/);
63+
await expect(page.locator('#errorStat')).toContainText('err');
64+
});
65+
66+
test('notes mode can be toggled', async ({ page }) => {
67+
await openFreshGame(page);
68+
69+
const notesBtn = page.locator('#notesModeBtn');
70+
await notesBtn.click();
71+
await expect(notesBtn).toContainText('Notes On');
72+
await expect(notesBtn).toHaveClass(/active/);
73+
});
74+
75+
test('saved game can be resumed', async ({ page }) => {
76+
await openFreshGame(page);
77+
78+
await page.evaluate(() => {
79+
window.__shandokuTest.setBoardState({
80+
grid: [
81+
[7, 0, 0, 0, 0, 0, 0, 0, 0],
82+
[0, 0, 0, 0, 0, 0, 0, 0, 0],
83+
[0, 0, 0, 0, 0, 0, 0, 0, 0],
84+
[0, 0, 0, 0, 0, 0, 0, 0, 0],
85+
[0, 0, 0, 0, 0, 0, 0, 0, 0],
86+
[0, 0, 0, 0, 0, 0, 0, 0, 0],
87+
[0, 0, 0, 0, 0, 0, 0, 0, 0],
88+
[0, 0, 0, 0, 0, 0, 0, 0, 0],
89+
[0, 0, 0, 0, 0, 0, 0, 0, 0]
90+
],
91+
startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0))
92+
});
93+
});
94+
95+
await page.reload();
96+
97+
await expect(page.locator('#resumeModal')).toBeVisible();
98+
await page.getByRole('button', { name: 'Resume' }).click();
99+
100+
await expect(page.locator('.cell[data-r="0"][data-c="0"]')).toHaveText('7');
101+
});
102+
});

0 commit comments

Comments
 (0)