rubiks_tutor / frontend /src /scene /cube /cubelets.test.ts
sanggusti's picture
feat: add unit tests for MoveAnimator and Cubelet functionality
5d8d1cc
Raw
History Blame Contribute Delete
5.98 kB
import * as THREE from 'three';
import { describe, it, expect } from 'vitest';
import { Cubelet, FACE_NAMES, type Axis, type Direction } from './cubelets';
import type { FaceKey } from '../../core/state';
import { _internal } from './animator';
const AXES: Axis[] = ['x', 'y', 'z'];
const DIRS: Direction[] = [1, -1];
// Same id encoding used by CubeMesh: makes addressX/Y/Z line up with (x,y,z).
function idFor(x: number, y: number, z: number): number {
return (x + 1) + (1 - y) * 3 + (1 - z) * 9;
}
// A cubelet whose every face is colored with its own solved-orientation normal,
// so we can read which original face now occupies each slot.
function labeledCubelet(): Cubelet {
const colors: FaceKey[] = ['F', 'U', 'R', 'D', 'L', 'B'];
return new Cubelet(0, colors);
}
describe('Cubelet construction', () => {
it('classifies type by number of stickered faces', () => {
expect(new Cubelet(0, []).type).toBe('core');
expect(new Cubelet(0, ['U']).type).toBe('center');
expect(new Cubelet(0, ['U', 'F']).type).toBe('edge');
expect(new Cubelet(0, ['U', 'F', 'R']).type).toBe('corner');
});
it('exposes faces in slot order with stable normals', () => {
const c = labeledCubelet();
expect(c.faces.map(f => f.normal)).toEqual(['F', 'U', 'R', 'D', 'L', 'B']);
expect(c.front.color).toBe('F');
expect(c.up.color).toBe('U');
expect(c.right.color).toBe('R');
expect(c.down.color).toBe('D');
expect(c.left.color).toBe('L');
expect(c.back.color).toBe('B');
});
it('renders colors string with "-" for interior faces', () => {
expect(new Cubelet(0, ['U', null, 'R']).colors).toBe('U-R---');
});
});
describe('Cubelet.setAddress', () => {
it('maps address to lattice coords for every position', () => {
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
const c = new Cubelet(idFor(x, y, z));
expect([c.addressX, c.addressY, c.addressZ]).toEqual([x, y, z]);
}
}
}
});
});
describe('Cubelet.rotate — face permutation', () => {
it('y+1 moves left->front, front->right, right->back, back->left', () => {
const c = labeledCubelet();
c.rotate('y', 1);
expect(c.colors).toBe('LUFDBR'); // front=L up=U right=F down=D left=B back=R
});
it('x+1 moves up->front, back->up, down->back, front->down', () => {
const c = labeledCubelet();
c.rotate('x', 1);
expect(c.colors).toBe('UBRFLD'); // front=U up=B right=R down=F left=L back=D
});
it('z+1 moves up->right, right->down, down->left, left->up', () => {
const c = labeledCubelet();
c.rotate('z', 1);
expect(c.colors).toBe('FRDLUB'); // front=F up=R right=D down=L left=U back=B
});
});
describe('Cubelet.rotate — algebraic invariants', () => {
it('four quarter turns about any axis is the identity', () => {
for (const axis of AXES) {
for (const dir of DIRS) {
const c = new Cubelet(idFor(1, 1, 1), ['F', 'U', 'R', 'D', 'L', 'B']);
const beforeColors = c.colors;
const beforeAddr = [c.addressX, c.addressY, c.addressZ];
for (let i = 0; i < 4; i++) c.rotate(axis, dir);
expect(c.colors).toBe(beforeColors);
expect([c.addressX, c.addressY, c.addressZ]).toEqual(beforeAddr);
}
}
});
it('a turn followed by its inverse is the identity', () => {
for (const axis of AXES) {
const c = new Cubelet(idFor(1, 1, 1), ['F', 'U', 'R', 'D', 'L', 'B']);
const beforeColors = c.colors;
const beforeAddr = [c.addressX, c.addressY, c.addressZ];
c.rotate(axis, 1);
c.rotate(axis, -1);
expect(c.colors).toBe(beforeColors);
expect([c.addressX, c.addressY, c.addressZ]).toEqual(beforeAddr);
}
});
it('preserves the multiset of face normals', () => {
const c = labeledCubelet();
c.rotate('x', 1);
c.rotate('y', -1);
c.rotate('z', 1);
expect([...c.faces.map(f => f.normal)].sort()).toEqual(['B', 'D', 'F', 'L', 'R', 'U']);
});
});
describe('Cubelet.rotate — lattice motion matches the visual animator', () => {
it('address rotation equals animator rotateCoord for all axes/dirs/positions', () => {
for (const axis of AXES) {
for (const dir of DIRS) {
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
const c = new Cubelet(idFor(x, y, z));
c.rotate(axis, dir);
const v = new THREE.Vector3(x, y, z);
_internal.rotateCoord(v, axis, dir);
// Compare against the visual coord, normalizing -0 to 0.
expect([c.addressX, c.addressY, c.addressZ]).toEqual([v.x + 0, v.y + 0, v.z + 0]);
}
}
}
}
}
});
});
describe('Cubelet color queries', () => {
it('hasColor returns the face name carrying a color', () => {
const c = labeledCubelet();
expect(c.hasColor('R')).toBe('right');
c.rotate('y', 1); // R moves into the back slot
expect(c.hasColor('R')).toBe('back');
});
it('hasColors requires every color to be present', () => {
const c = new Cubelet(0, ['U', 'F', 'R']);
expect(c.hasColors('U', 'F', 'R')).toBe(true);
expect(c.hasColors('U', 'B')).toBe(false);
});
});
it('FACE_NAMES is the canonical slot order', () => {
expect(FACE_NAMES).toEqual(['front', 'up', 'right', 'down', 'left', 'back']);
});