From e36a988da9a4bf31d1d10c5dcb2652aa5ecba6c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:09:18 +0000 Subject: [PATCH 1/3] Audit: extract testable cube logic, optimize renderer, harden PWA caching Refactor and tighten the CubeFlow trainer without changing its zero-build, browser-only deployment model. Testability - Extract pure cube-state logic (validation, parity, scramble, move helpers) into src/cube-logic.js with no DOM/Three.js dependencies so it can run under Node. app.js now imports these and maps machine- readable validation codes to its user-facing copy. - Add a node:test suite (test/cube-logic.test.mjs) and package.json dev tooling. Covers count/center/corner/edge/parity validation, scramble well-formedness, and move inversion round-trips. Renderer performance - Share the shell geometry, shell material, and sticker geometry across all cubelets instead of cloning them on every rebuild (each move rebuilds the cube), cutting per-move allocations sharply. - Replace the always-on requestAnimationFrame loop with on-demand rendering: frames are drawn only when the view changes or damping is still settling, so an idle cube no longer renders 60fps forever. PWA reliability - Service worker now uses stale-while-revalidate for same-origin assets so deployments reach returning visitors, precaches the new logic module, and keeps cache-first for immutable CDN libraries. Tooling and docs - Add the GitHub Pages workflow the README references (runs tests, then deploys the static site); update README architecture and add a Tests section; ignore node_modules. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XDijW82Hm9yCyUpntvaRRp --- .github/workflows/pages.yml | 45 +++++++++++ .gitignore | 4 + README.md | 19 ++++- package.json | 14 ++++ src/app.js | 121 +++++++++--------------------- src/cube-logic.js | 97 ++++++++++++++++++++++++ src/cube-renderer.js | 67 +++++++++++------ sw.js | 31 +++++++- test/cube-logic.test.mjs | 144 ++++++++++++++++++++++++++++++++++++ 9 files changed, 430 insertions(+), 112 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/cube-logic.js create mode 100644 test/cube-logic.test.mjs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..8c811f6 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,45 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; cancel superseded runs. +concurrency: + group: pages + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + - run: npm test + + deploy: + needs: test + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - name: Upload site artifact + uses: actions/upload-pages-artifact@v3 + with: + # Publish the repository root; the app is a static site with no build step. + path: . + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44da058 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +npm-debug.log* +.DS_Store diff --git a/README.md b/README.md index d2b716b..1e39b58 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,17 @@ or use the Live Server extension in VS Code. Then open: http://localhost:8080 ``` +## Tests + +The app itself ships with no build step, but the pure cube-state logic in +`src/cube-logic.js` (validation, parity, scramble generation, move helpers) has a +Node test suite. It runs in CI on every push and can be run locally: + +```bash +npm install # dev-only: installs the cube.js engine and the test runner +npm test # node --test +``` + ## Keyboard controls | Key | Action | @@ -82,10 +93,14 @@ cubeflow/ ├── styles.css ├── manifest.webmanifest ├── sw.js +├── package.json # dev tooling only (test runner); the app needs no build ├── src/ -│ ├── app.js -│ ├── cube-renderer.js +│ ├── app.js # UI, state, and event wiring +│ ├── cube-logic.js # pure cube-state logic (no DOM/Three), shared with tests +│ ├── cube-renderer.js # Three.js 3D rendering │ └── solver-worker.js +├── test/ +│ └── cube-logic.test.mjs ├── assets/ │ └── icon.svg └── .github/workflows/pages.yml diff --git a/package.json b/package.json new file mode 100644 index 0000000..375fb1d --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "cubeflow", + "version": "1.0.0", + "description": "Accessible browser-only 3x3 Rubik's Cube trainer and guided solver.", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "test": "node --test" + }, + "devDependencies": { + "cubejs": "1.3.2" + } +} diff --git a/src/app.js b/src/app.js index 4fe8b9d..1fff377 100644 --- a/src/app.js +++ b/src/app.js @@ -1,9 +1,16 @@ import { CubeRenderer, STICKER_COLORS } from './cube-renderer.js'; +import { + SOLVED_STATE, + FACE_ORDER, + CENTER_INDICES, + countColors, + inverseMove, + applyMoveToState, + generateScramble, + validatePhysical +} from './cube-logic.js'; const Cube = window.Cube; -const SOLVED_STATE = 'UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB'; -const FACE_ORDER = ['U', 'R', 'F', 'D', 'L', 'B']; -const CENTER_INDICES = new Set([4, 13, 22, 31, 40, 49]); const FACE_NAMES = { U: 'Up', R: 'Right', F: 'Front', D: 'Down', L: 'Left', B: 'Back' }; @@ -172,14 +179,6 @@ function colorStyle(letter) { return `--sticker-color:${color.css};--label-color:${color.label}`; } -function countColors(state = facelets) { - const counts = Object.fromEntries(FACE_ORDER.map((face) => [face, 0])); - [...state].forEach((letter) => { - if (counts[letter] !== undefined) counts[letter] += 1; - }); - return counts; -} - function renderPalette() { elements.palette.replaceChildren(); FACE_ORDER.forEach((letter) => { @@ -203,7 +202,7 @@ function renderPalette() { } function renderCounts() { - const counts = countColors(); + const counts = countColors(facelets); elements.counts.replaceChildren(); FACE_ORDER.forEach((letter) => { const color = STICKER_COLORS[letter]; @@ -290,68 +289,34 @@ function paintSticker(index) { setValidation('neutral', 'Cube changed', 'Continue matching your real cube, then run the check.'); } -function isPermutation(array, length) { - return Array.isArray(array) - && array.length === length - && new Set(array).size === length - && array.every((value) => Number.isInteger(value) && value >= 0 && value < length); -} - -function permutationParity(array) { - let inversions = 0; - for (let i = 0; i < array.length; i += 1) { - for (let j = i + 1; j < array.length; j += 1) { - if (array[i] > array[j]) inversions += 1; +function validationMessage(result) { + switch (result.code) { + case 'count': { + const details = FACE_ORDER + .filter((face) => result.counts[face] !== 9) + .map((face) => `${STICKER_COLORS[face].name}: ${result.counts[face]}`) + .join(', '); + return `Each color needs nine stickers. Check ${details}.`; } + case 'center': + return 'A center color was changed. Reset the cube orientation and try again.'; + case 'permutation': + return 'Some corner or edge color combinations cannot exist on a real cube. Recheck the affected pieces.'; + case 'corner-orientation': + return 'A corner appears twisted incorrectly. Recheck all three stickers on each corner.'; + case 'edge-orientation': + return 'An edge appears flipped incorrectly. Recheck the two stickers on each edge.'; + case 'parity': + return 'Two pieces appear swapped. Recheck the color entry around the entire cube.'; + default: + return 'This color arrangement is not a valid physical cube. Recheck the stickers and orientation.'; } - return inversions % 2; } function inspectState(state = facelets) { - const counts = countColors(state); - const countProblems = FACE_ORDER.filter((face) => counts[face] !== 9); - if (countProblems.length) { - const details = countProblems.map((face) => `${STICKER_COLORS[face].name}: ${counts[face]}`).join(', '); - return { valid: false, message: `Each color needs nine stickers. Check ${details}.` }; - } - - for (let face = 0; face < 6; face += 1) { - const expected = FACE_ORDER[face]; - if (state[face * 9 + 4] !== expected) { - return { valid: false, message: 'A center color was changed. Reset the cube orientation and try again.' }; - } - } - - try { - const cube = Cube.fromString(state); - const data = cube.toJSON(); - if (!isPermutation(data.cp, 8) || !isPermutation(data.ep, 12)) { - return { valid: false, message: 'Some corner or edge color combinations cannot exist on a real cube. Recheck the affected pieces.' }; - } - if (data.co.some((value) => value < 0 || value > 2) || data.co.reduce((sum, value) => sum + value, 0) % 3 !== 0) { - return { valid: false, message: 'A corner appears twisted incorrectly. Recheck all three stickers on each corner.' }; - } - if (data.eo.some((value) => value < 0 || value > 1) || data.eo.reduce((sum, value) => sum + value, 0) % 2 !== 0) { - return { valid: false, message: 'An edge appears flipped incorrectly. Recheck the two stickers on each edge.' }; - } - if (permutationParity(data.cp) !== permutationParity(data.ep)) { - return { valid: false, message: 'Two pieces appear swapped. Recheck the color entry around the entire cube.' }; - } - return { valid: true, cube, solved: cube.isSolved() }; - } catch (error) { - return { valid: false, message: 'This color arrangement is not a valid physical cube. Recheck the stickers and orientation.' }; - } -} - -function applyMoveToState(state, move) { - const cube = Cube.fromString(state); - cube.move(move); - return cube.asString(); -} - -function inverseMove(move) { - if (move.includes('2')) return move; - return move.includes("'") ? move[0] : `${move[0]}'`; + const result = validatePhysical(Cube, state); + if (result.valid) return { valid: true, cube: result.cube, solved: result.solved }; + return { valid: false, message: validationMessage(result) }; } async function performMove(move, { source = 'button', record = true } = {}) { @@ -364,7 +329,7 @@ async function performMove(move, { source = 'button', record = true } = {}) { } if (record) history.push(facelets); - const nextState = applyMoveToState(facelets, move); + const nextState = applyMoveToState(Cube, facelets, move); isBusy = true; const duration = Number(elements.speedSelect.value || 580); if (renderer) await renderer.animateMove(move, nextState, duration); @@ -429,20 +394,6 @@ function undo() { announce('Last change undone.'); } -function generateScramble(length = 20) { - const faces = ['U', 'R', 'F', 'D', 'L', 'B']; - const suffixes = ['', "'", '2']; - const moves = []; - let previousFace = ''; - while (moves.length < length) { - const face = faces[Math.floor(Math.random() * faces.length)]; - if (face === previousFace) continue; - previousFace = face; - moves.push(`${face}${suffixes[Math.floor(Math.random() * suffixes.length)]}`); - } - return moves; -} - function setSolved() { history.push(facelets); setState(SOLVED_STATE, { invalidate: true }); @@ -539,7 +490,7 @@ function loadSolution(moves) { solutionStates = [solutionStartState]; let state = solutionStartState; moves.forEach((move) => { - state = applyMoveToState(state, move); + state = applyMoveToState(Cube, state, move); solutionStates.push(state); }); solutionIndex = 0; diff --git a/src/cube-logic.js b/src/cube-logic.js new file mode 100644 index 0000000..41f5749 --- /dev/null +++ b/src/cube-logic.js @@ -0,0 +1,97 @@ +// Pure cube-state logic shared by the app UI and the test suite. +// This module has no DOM or Three.js dependencies, so it runs unchanged in the +// browser and under Node (see test/cube-logic.test.mjs). Functions that need a +// cube engine take the `Cube` implementation as their first argument, so the +// browser can pass the global cube.js build and tests can pass the npm package. + +export const SOLVED_STATE = 'UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB'; +export const FACE_ORDER = ['U', 'R', 'F', 'D', 'L', 'B']; +export const CENTER_INDICES = new Set([4, 13, 22, 31, 40, 49]); + +export function countColors(state) { + const counts = Object.fromEntries(FACE_ORDER.map((face) => [face, 0])); + for (const letter of state) { + if (counts[letter] !== undefined) counts[letter] += 1; + } + return counts; +} + +export function isPermutation(array, length) { + return Array.isArray(array) + && array.length === length + && new Set(array).size === length + && array.every((value) => Number.isInteger(value) && value >= 0 && value < length); +} + +export function permutationParity(array) { + let inversions = 0; + for (let i = 0; i < array.length; i += 1) { + for (let j = i + 1; j < array.length; j += 1) { + if (array[i] > array[j]) inversions += 1; + } + } + return inversions % 2; +} + +export function inverseMove(move) { + if (move.includes('2')) return move; + return move.includes("'") ? move[0] : `${move[0]}'`; +} + +export function applyMoveToState(Cube, state, move) { + const cube = Cube.fromString(state); + cube.move(move); + return cube.asString(); +} + +// `random` is injectable so tests can produce deterministic scrambles. +export function generateScramble(length = 20, random = Math.random) { + const suffixes = ['', "'", '2']; + const moves = []; + let previousFace = ''; + while (moves.length < length) { + const face = FACE_ORDER[Math.floor(random() * FACE_ORDER.length)]; + if (face === previousFace) continue; + previousFace = face; + moves.push(`${face}${suffixes[Math.floor(random() * suffixes.length)]}`); + } + return moves; +} + +// Validates that a 54-character facelet string represents a physically solvable +// cube. Returns a machine-readable `code` so the UI can attach its own copy and +// tests can assert without matching prose. On success the live `cube` instance +// and its solved flag are included. +export function validatePhysical(Cube, state) { + const counts = countColors(state); + const hasCountProblem = FACE_ORDER.some((face) => counts[face] !== 9); + if (hasCountProblem) return { valid: false, code: 'count', counts }; + + for (let face = 0; face < FACE_ORDER.length; face += 1) { + if (state[face * 9 + 4] !== FACE_ORDER[face]) { + return { valid: false, code: 'center', counts }; + } + } + + try { + const cube = Cube.fromString(state); + const data = cube.toJSON(); + if (!isPermutation(data.cp, 8) || !isPermutation(data.ep, 12)) { + return { valid: false, code: 'permutation', counts }; + } + if (data.co.some((value) => value < 0 || value > 2) + || data.co.reduce((sum, value) => sum + value, 0) % 3 !== 0) { + return { valid: false, code: 'corner-orientation', counts }; + } + if (data.eo.some((value) => value < 0 || value > 1) + || data.eo.reduce((sum, value) => sum + value, 0) % 2 !== 0) { + return { valid: false, code: 'edge-orientation', counts }; + } + if (permutationParity(data.cp) !== permutationParity(data.ep)) { + return { valid: false, code: 'parity', counts }; + } + return { valid: true, code: 'ok', counts, cube, solved: cube.isSolved() }; + } catch (error) { + return { valid: false, code: 'unsolvable', counts }; + } +} diff --git a/src/cube-renderer.js b/src/cube-renderer.js index 598c541..5aa26ba 100644 --- a/src/cube-renderer.js +++ b/src/cube-renderer.js @@ -77,11 +77,25 @@ export class CubeRenderer { this.stickerByIndex = new Map(); this.pointerSession = null; this.hoveredSticker = null; + this.renderQueued = false; + this.renderFrame = this.renderFrame.bind(this); + + // Shared, immutable resources reused across every rebuild. Cubelet shells + // are visually identical, so they can share one geometry and one material; + // only the per-sticker materials (which carry individual colours and hover + // highlights) are created and disposed per rebuild. + this.shellGeometry = new RoundedBoxGeometry(0.93, 0.93, 0.93, 4, 0.075); + this.stickerGeometry = new RoundedBoxGeometry(0.76, 0.76, 0.035, 3, 0.016); + this.shellMaterial = new THREE.MeshStandardMaterial({ + color: 0x080b10, + roughness: 0.34, + metalness: 0.16 + }); this.initScene(); this.bindEvents(); this.buildCube(this.currentState); - this.animateFrame(); + this.requestRender(); } initScene() { @@ -109,6 +123,8 @@ export class CubeRenderer { this.controls.minDistance = 6.2; this.controls.maxDistance = 12; this.controls.target.set(0, 0, 0); + // Re-render whenever the user orbits or damping is still settling. + this.controls.addEventListener('change', () => this.requestRender()); this.cubeRoot = new THREE.Group(); this.cubeRoot.rotation.set(-0.08, 0.08, 0.02); @@ -155,15 +171,9 @@ export class CubeRenderer { } disposeCube() { - this.cubelets.forEach((cubelet) => { - cubelet.traverse((child) => { - if (child.geometry) child.geometry.dispose(); - if (child.material) { - if (Array.isArray(child.material)) child.material.forEach((material) => material.dispose()); - else child.material.dispose(); - } - }); - }); + // Only per-sticker materials are owned per build; shared shell geometry, + // shell material, and sticker geometry live for the renderer's lifetime. + this.stickerMeshes.forEach((sticker) => sticker.material.dispose()); while (this.cubeRoot.children.length) { this.cubeRoot.remove(this.cubeRoot.children[0]); } @@ -176,14 +186,6 @@ export class CubeRenderer { this.currentState = state; this.disposeCube(); - const shellGeometry = new RoundedBoxGeometry(0.93, 0.93, 0.93, 4, 0.075); - const shellMaterial = new THREE.MeshStandardMaterial({ - color: 0x080b10, - roughness: 0.34, - metalness: 0.16 - }); - const stickerGeometry = new RoundedBoxGeometry(0.76, 0.76, 0.035, 3, 0.016); - for (let x = -1; x <= 1; x += 1) { for (let y = -1; y <= 1; y += 1) { for (let z = -1; z <= 1; z += 1) { @@ -193,7 +195,7 @@ export class CubeRenderer { cubelet.position.set(x * 1.01, y * 1.01, z * 1.01); cubelet.userData.coord = { x, y, z }; - const shell = new THREE.Mesh(shellGeometry.clone(), shellMaterial.clone()); + const shell = new THREE.Mesh(this.shellGeometry, this.shellMaterial); shell.castShadow = true; shell.receiveShadow = true; cubelet.add(shell); @@ -217,7 +219,7 @@ export class CubeRenderer { emissive: color.hex, emissiveIntensity: 0 }); - const sticker = new THREE.Mesh(stickerGeometry.clone(), material); + const sticker = new THREE.Mesh(this.stickerGeometry, material); const transform = stickerTransform(face); sticker.position.set(...transform.position); sticker.rotation.set(...transform.rotation); @@ -233,6 +235,8 @@ export class CubeRenderer { } } } + + this.requestRender(); } updateSticker(index, colorLetter) { @@ -259,6 +263,7 @@ export class CubeRenderer { this.camera.position.set(6.6, 5.5, 7.3); this.controls.target.set(0, 0, 0); this.controls.update(); + this.requestRender(); } resize() { @@ -267,6 +272,7 @@ export class CubeRenderer { this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height, false); + this.requestRender(); } getStickerFromPointer(event) { @@ -354,6 +360,7 @@ export class CubeRenderer { if (sticker) { sticker.material.emissiveIntensity = 0.22; this.canvas.style.cursor = 'crosshair'; + this.requestRender(); } } @@ -361,6 +368,7 @@ export class CubeRenderer { if (this.hoveredSticker) { this.hoveredSticker.material.emissiveIntensity = 0; this.hoveredSticker = null; + this.requestRender(); } this.canvas.style.cursor = this.mode === 'paint' ? 'crosshair' : 'grab'; } @@ -408,6 +416,8 @@ export class CubeRenderer { const raw = Math.min(1, (now - start) / animationDuration); const eased = 1 - Math.pow(1 - raw, 3); pivot.quaternion.slerpQuaternions(from, to, eased); + this.controls.update(); + this.renderer.render(this.scene, this.camera); if (raw < 1) requestAnimationFrame(tick); else resolve(); }; @@ -420,9 +430,20 @@ export class CubeRenderer { return true; } - animateFrame() { - this.controls.update(); + // On-demand rendering: instead of a permanent requestAnimationFrame loop that + // renders 60 times a second even when the cube is idle, we render only when + // something changes. While OrbitControls damping is still settling, update() + // reports a change and we schedule the next frame; once it settles we stop. + requestRender() { + if (this.renderQueued) return; + this.renderQueued = true; + requestAnimationFrame(this.renderFrame); + } + + renderFrame() { + this.renderQueued = false; + const changed = this.controls.update(); this.renderer.render(this.scene, this.camera); - requestAnimationFrame(() => this.animateFrame()); + if (changed) this.requestRender(); } } diff --git a/sw.js b/sw.js index 0a720dd..bd9f37b 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = 'cubeflow-v1'; +const CACHE_VERSION = 'cubeflow-v2'; const APP_SHELL = [ './', './index.html', @@ -6,6 +6,7 @@ const APP_SHELL = [ './manifest.webmanifest', './assets/icon.svg', './src/app.js', + './src/cube-logic.js', './src/cube-renderer.js', './src/solver-worker.js' ]; @@ -33,6 +34,8 @@ self.addEventListener('fetch', (event) => { const isNavigation = event.request.mode === 'navigate'; const isLocal = requestUrl.origin === self.location.origin; + // Navigations: network-first so a fresh page is served when online, with the + // cached shell as an offline fallback. if (isNavigation) { event.respondWith( fetch(event.request) @@ -46,11 +49,35 @@ self.addEventListener('fetch', (event) => { return; } + // Same-origin assets: stale-while-revalidate. Serve the cached copy instantly + // for speed and offline support, but always refresh the cache in the + // background so a new deployment reaches returning visitors on their next + // load — even if the cache version is not bumped. + if (isLocal) { + event.respondWith( + caches.open(CACHE_VERSION).then((cache) => + cache.match(event.request).then((cached) => { + const network = fetch(event.request) + .then((response) => { + if (response.ok) cache.put(event.request, response.clone()); + return response; + }) + .catch(() => cached); + return cached || network; + }) + ) + ); + return; + } + + // Cross-origin (versioned CDN libraries): cache-first, since those URLs are + // immutable. Cache opaque responses too so the app works offline after the + // first successful online visit. event.respondWith( caches.match(event.request).then((cached) => { if (cached) return cached; return fetch(event.request).then((response) => { - if (response.ok || response.type === 'opaque' || isLocal) { + if (response.ok || response.type === 'opaque') { const copy = response.clone(); caches.open(CACHE_VERSION).then((cache) => cache.put(event.request, copy)); } diff --git a/test/cube-logic.test.mjs b/test/cube-logic.test.mjs new file mode 100644 index 0000000..57e62b0 --- /dev/null +++ b/test/cube-logic.test.mjs @@ -0,0 +1,144 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Cube from 'cubejs'; + +import { + SOLVED_STATE, + FACE_ORDER, + countColors, + isPermutation, + permutationParity, + inverseMove, + applyMoveToState, + generateScramble, + validatePhysical +} from '../src/cube-logic.js'; + +// Replace the character at `index` of a facelet string. +function withSticker(state, index, letter) { + return `${state.slice(0, index)}${letter}${state.slice(index + 1)}`; +} + +// Small seedable PRNG so scramble tests are deterministic. +function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +test('countColors tallies each of the six face letters', () => { + assert.deepEqual(countColors(SOLVED_STATE), { U: 9, R: 9, F: 9, D: 9, L: 9, B: 9 }); + const off = withSticker(SOLVED_STATE, 0, 'R'); + assert.equal(countColors(off).U, 8); + assert.equal(countColors(off).R, 10); +}); + +test('isPermutation accepts valid permutations and rejects bad input', () => { + assert.equal(isPermutation([0, 1, 2, 3], 4), true); + assert.equal(isPermutation([3, 2, 1, 0], 4), true); + assert.equal(isPermutation([0, 1, 1, 3], 4), false); // duplicate + assert.equal(isPermutation([0, 1, 2], 4), false); // wrong length + assert.equal(isPermutation([0, 1, 2, 4], 4), false); // out of range + assert.equal(isPermutation('nope', 4), false); +}); + +test('permutationParity counts inversions modulo two', () => { + assert.equal(permutationParity([0, 1, 2, 3]), 0); + assert.equal(permutationParity([1, 0, 2, 3]), 1); // one swap + assert.equal(permutationParity([1, 0, 3, 2]), 0); // two swaps +}); + +test('inverseMove reverses quarter turns and leaves double turns alone', () => { + assert.equal(inverseMove('R'), "R'"); + assert.equal(inverseMove("R'"), 'R'); + assert.equal(inverseMove('U'), "U'"); + assert.equal(inverseMove('U2'), 'U2'); + assert.equal(inverseMove('F2'), 'F2'); +}); + +test('applyMoveToState followed by its inverse returns the original state', () => { + const once = applyMoveToState(Cube, SOLVED_STATE, 'R'); + assert.notEqual(once, SOLVED_STATE); + const back = applyMoveToState(Cube, once, inverseMove('R')); + assert.equal(back, SOLVED_STATE); +}); + +test('generateScramble is deterministic, well-formed, and avoids immediate repeats', () => { + const scramble = generateScramble(20, mulberry32(42)); + assert.equal(scramble.length, 20); + + const tokenPattern = /^[URFDLB]('|2)?$/; + for (const move of scramble) { + assert.match(move, tokenPattern); + } + for (let i = 1; i < scramble.length; i += 1) { + assert.notEqual(scramble[i][0], scramble[i - 1][0], 'two consecutive moves share a face'); + } + + // Same seed reproduces the same sequence. + assert.deepEqual(generateScramble(20, mulberry32(42)), scramble); +}); + +test('generated scrambles always validate as physically solvable', () => { + for (let seed = 1; seed <= 25; seed += 1) { + const scramble = generateScramble(20, mulberry32(seed)); + const cube = new Cube(); + cube.move(scramble.join(' ')); + const result = validatePhysical(Cube, cube.asString()); + assert.equal(result.valid, true, `seed ${seed} produced an invalid cube`); + assert.equal(result.code, 'ok'); + } +}); + +test('validatePhysical accepts the solved cube', () => { + const result = validatePhysical(Cube, SOLVED_STATE); + assert.equal(result.valid, true); + assert.equal(result.solved, true); +}); + +test('validatePhysical flags wrong sticker counts', () => { + const result = validatePhysical(Cube, withSticker(SOLVED_STATE, 0, 'R')); + assert.equal(result.valid, false); + assert.equal(result.code, 'count'); +}); + +test('validatePhysical flags a moved center even when counts are balanced', () => { + // Swap a U sticker onto the R center and put an R onto a U-face cell so every + // colour still appears nine times; only the center is wrong. + let state = withSticker(SOLVED_STATE, 4, 'R'); // U center becomes R + state = withSticker(state, 9, 'U'); // an R-face cell becomes U + assert.deepEqual(countColors(state), { U: 9, R: 9, F: 9, D: 9, L: 9, B: 9 }); + const result = validatePhysical(Cube, state); + assert.equal(result.valid, false); + assert.equal(result.code, 'center'); +}); + +test('validatePhysical flags a single twisted corner (URF)', () => { + // Cycle the three stickers of the URF corner: U9, R1, F3. + let state = withSticker(SOLVED_STATE, 8, 'F'); + state = withSticker(state, 9, 'U'); + state = withSticker(state, 20, 'R'); + assert.deepEqual(countColors(state), { U: 9, R: 9, F: 9, D: 9, L: 9, B: 9 }); + const result = validatePhysical(Cube, state); + assert.equal(result.valid, false); + assert.equal(result.code, 'corner-orientation'); +}); + +test('validatePhysical flags a single flipped edge (UF)', () => { + // Swap the two stickers of the UF edge: U8 and F2. + let state = withSticker(SOLVED_STATE, 7, 'F'); + state = withSticker(state, 19, 'U'); + assert.deepEqual(countColors(state), { U: 9, R: 9, F: 9, D: 9, L: 9, B: 9 }); + const result = validatePhysical(Cube, state); + assert.equal(result.valid, false); + assert.equal(result.code, 'edge-orientation'); +}); + +test('FACE_ORDER is the canonical URFDLB ordering', () => { + assert.deepEqual(FACE_ORDER, ['U', 'R', 'F', 'D', 'L', 'B']); +}); From afb2f4d8ed2cc39a3de1193902fc52ae540fa223 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 16:46:56 +0000 Subject: [PATCH 2/3] Run tests on pull requests, not just pushes to main The Pages workflow only triggered on push to main, so PRs had zero CI signal before merge. Split into a test.yml that runs on every PR and push, and keep pages.yml focused on deploying main (it still runs the test suite as a pre-deploy gate). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XDijW82Hm9yCyUpntvaRRp --- .github/workflows/pages.yml | 14 ++++---------- .github/workflows/test.yml | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 8c811f6..f218a2a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -16,8 +16,11 @@ concurrency: cancel-in-progress: true jobs: - test: + deploy: runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -25,15 +28,6 @@ jobs: node-version: 20 - run: npm install - run: npm test - - deploy: - needs: test - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - uses: actions/configure-pages@v5 - name: Upload site artifact uses: actions/upload-pages-artifact@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..adec2ba --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + - run: npm test From c8141466f9aab3fd7d3cf29a7ec1c75ee5344843 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:09:35 +0000 Subject: [PATCH 3/3] Fix hidden elements always rendering due to CSS cascade bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several elements (canvas-fallback, solve-empty, secondary-button/install -button, cube-canvas) set `display` unconditionally in a normal-priority author rule, which always wins over the user-agent stylesheet's `[hidden] { display: none }` regardless of specificity. This made the "3D view unavailable" fallback panel render on top of the working 3D canvas on every single page load for every visitor, independent of browser, WebGL, or CDN availability — reported as a live bug on GitHub Pages and confirmed via DevTools (fallback.hidden was true, WebGL was hardware-accelerated, and the Three.js/cube.js CDN requests returned 200, yet the panel stayed visible). Add a single global `[hidden] { display: none !important; }` rule so the native hidden attribute works everywhere, instead of patching each affected selector individually. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XDijW82Hm9yCyUpntvaRRp --- styles.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/styles.css b/styles.css index d2795d0..03dab06 100644 --- a/styles.css +++ b/styles.css @@ -44,6 +44,16 @@ html[data-contrast="high"] { box-sizing: border-box; } +/* A normal-priority author `display` declaration (e.g. `.canvas-fallback { + display: grid }`) always wins over the user-agent stylesheet's + `[hidden] { display: none }`, regardless of selector specificity. Several + elements in this app are toggled via the `hidden` IDL property while also + matching a class/id rule that sets `display`, which silently defeats the + native hidden behavior. This rule restores it everywhere. */ +[hidden] { + display: none !important; +} + html { min-width: 320px; background: var(--bg);