diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..f218a2a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,39 @@ +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: + 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 + with: + node-version: 20 + - run: npm install + - run: npm test + - 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/.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 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/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); 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']); +});