import { describe, expect, it } from 'vitest'; import { canonicalizeShareableAnimationState, createShareableAnimationSearchParams, decodeShareableAnimationState, encodeShareableAnimationState, hasShareableAnimationState, } from './shareableAnimationState'; describe('shareableAnimationState', () => { it('encodes a compact canonical animation/view state and round-trips through the decoder', () => { const encoded = encodeShareableAnimationState({ machineId: ' wankel-rotary-engine ', animationId: 'main-cycle', timeSeconds: 1.23456, rpm: 742.555, timeScale: 0.333333, playing: false, loop: true, stepMode: true, reducedMotion: false, exploded: 0.4567, renderMode: 'cross-section', componentId: 'rotor', tourId: 'combustion-tour', tourStep: 2, camera: { preset: 'cutaway', position: [1.1111, -2.2222, 3], target: [0, 0, 0], zoom: 1.23456, }, hiddenComponents: ['rotor', 'housing', 'rotor'], isolatedComponents: ['spark-plug', 'rotor'], componentOpacity: { housing: 0.4567, rotor: 1.2, 'seal:ring': -0.2, }, labels: true, crossSection: { axis: 'z', offset: 0.12345, inverted: true, }, }); expect(encoded.startsWith('v=1&m=wankel-rotary-engine&a=main-cycle')).toBe(true); expect(encoded).not.toContain('NaN'); const roundTrip = decodeShareableAnimationState(encoded).state; expect(roundTrip).toMatchObject({ version: 1, machineId: 'wankel-rotary-engine', animationId: 'main-cycle', componentId: 'rotor', tourId: 'combustion-tour', tourStep: 2, timeSeconds: 1.235, rpm: 742.56, timeScale: 0.333, playing: false, loop: true, stepMode: true, reducedMotion: false, exploded: 0.457, renderMode: 'cross-section', labels: true, crossSection: { axis: 'z', offset: 0.123, inverted: true, }, }); expect(roundTrip.camera).toEqual({ preset: 'cutaway', position: [1.111, -2.222, 3], target: [0, 0, 0], zoom: 1.235, }); expect(roundTrip.hiddenComponents).toEqual(['housing']); expect(roundTrip.isolatedComponents).toEqual(['rotor', 'spark-plug']); expect(roundTrip.componentOpacity).toEqual({ housing: 0.457, rotor: 1, 'seal:ring': 0, }); }); it('decodes malformed and out-of-range query values safely with warnings', () => { const result = decodeShareableAnimationState( '?m=unknown&t=-5&rpm=999999&s=0&play=maybe&loop=yes&mode=section&pos=1,NaN,3&exp=2&hide=a,,a&op=a:2,b:not&cut=q:999999:yes', { knownMachineIds: ['four-stroke-petrol-engine'], limits: { maxRpm: 9_000, maxCoordinateMagnitude: 50, }, }, ); expect(result.state.machineId).toBeUndefined(); expect(result.state.timeSeconds).toBe(0); expect(result.state.rpm).toBe(9_000); expect(result.state.timeScale).toBe(0.05); expect(result.state.playing).toBeUndefined(); expect(result.state.loop).toBe(true); expect(result.state.renderMode).toBe('cross-section'); expect(result.state.exploded).toBe(1); expect(result.state.camera).toBeUndefined(); expect(result.state.hiddenComponents).toEqual(['a']); expect(result.state.componentOpacity).toEqual({ a: 1 }); expect(result.state.crossSection).toEqual({ offset: 50, inverted: true, }); const warningCodes = result.warnings.map((warning) => warning.code); expect(warningCodes).toContain('unknown-identifier'); expect(warningCodes).toContain('invalid-boolean'); expect(warningCodes).toContain('invalid-camera-vector'); expect(warningCodes).toContain('invalid-axis'); expect(warningCodes).toContain('clamped-number'); }); it('preserves component ids that contain list delimiters', () => { const componentIds = ['seal:ring,outer', 'planet gear']; const encoded = encodeShareableAnimationState({ hiddenComponents: componentIds, componentOpacity: { 'seal:ring,outer': 0.25, }, }); const decoded = decodeShareableAnimationState(encoded).state; expect(decoded.hiddenComponents).toEqual([...componentIds].sort()); expect(decoded.componentOpacity).toEqual({ 'seal:ring,outer': 0.25, }); }); it('replaces existing share-state params while preserving unrelated query params', () => { const params = createShareableAnimationSearchParams( '?utm=course&m=old-machine&t=9&play=1', { machineId: 'centrifugal-pump', rpm: 800, playing: false, }, { includeVersion: false, }, ); expect(params.get('utm')).toBe('course'); expect(params.get('m')).toBe('centrifugal-pump'); expect(params.get('rpm')).toBe('800'); expect(params.get('play')).toBe('0'); expect(params.get('t')).toBeNull(); expect(params.get('v')).toBeNull(); }); it('reads animation state from hash-routed URLs', () => { const decoded = decodeShareableAnimationState( '/#/procedural-demo?m=geneva-drive&rpm=60&step=1', ); expect(decoded.state.machineId).toBe('geneva-drive'); expect(decoded.state.rpm).toBe(60); expect(decoded.state.stepMode).toBe(true); expect(decoded.unknownKeys).toEqual([]); }); it('detects and canonicalizes shareable state input', () => { expect(hasShareableAnimationState('?utm=campaign')).toBe(false); expect(hasShareableAnimationState('?utm=campaign&m=ball-bearing')).toBe(true); expect( canonicalizeShareableAnimationState( '?rpm=100.12345&m=ball-bearing&hide=outer-race,inner-race,outer-race', { includeVersion: false, }, ), ).toBe('m=ball-bearing&rpm=100.12&hide=inner-race%2Couter-race'); }); });