- New `activeBody` field in config store with `setBody` action and URL sync (`b` key, defaults to `basic` for backward compat) - Robot Body section in ConfigPanel between Persona Attire and Pricing - RobotModel base mesh extracted into BaseBodyMesh subcomponent wrapped in error boundary so a missing EDU GLB silently falls back to basic - Tests cover defaults, setBody, reset, and round-trip serialization Note: drop `/public/Unitree_G1_EDU.glb` to enable the EDU variant — until then EDU selection falls back to basic with a console warning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
231 lines
7.8 KiB
TypeScript
231 lines
7.8 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { configStore, ConfigState, Payload, serializeConfig, deserializeConfig } from '@/store/useConfigStore';
|
|
|
|
describe('useConfigStore (vanilla)', () => {
|
|
beforeEach(() => {
|
|
configStore.getState().reset();
|
|
});
|
|
|
|
describe('Initial State', () => {
|
|
it('should have correct default values', () => {
|
|
const state = configStore.getState();
|
|
|
|
expect(state.activeColors).toEqual({
|
|
primary: '#96a2b6',
|
|
secondary: '#1e293b',
|
|
accent: '#f59e0b',
|
|
});
|
|
expect(state.activePersonaAttire).toBe('none');
|
|
expect(state.activeBody).toBe('basic');
|
|
expect(state.activePayloads).toEqual([]);
|
|
expect(state.isHydrated).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('setBody', () => {
|
|
it('should update active body to edu', () => {
|
|
configStore.getState().setBody('edu');
|
|
expect(configStore.getState().activeBody).toBe('edu');
|
|
});
|
|
|
|
it('should update active body back to basic', () => {
|
|
configStore.getState().setBody('edu');
|
|
configStore.getState().setBody('basic');
|
|
expect(configStore.getState().activeBody).toBe('basic');
|
|
});
|
|
});
|
|
|
|
describe('setColors', () => {
|
|
it('should update primary color', () => {
|
|
configStore.getState().setColors({ primary: '#ff0000' });
|
|
|
|
const state = configStore.getState();
|
|
expect(state.activeColors.primary).toBe('#ff0000');
|
|
expect(state.activeColors.secondary).toBe('#1e293b');
|
|
expect(state.activeColors.accent).toBe('#f59e0b');
|
|
});
|
|
|
|
it('should update multiple colors at once', () => {
|
|
configStore.getState().setColors({
|
|
primary: '#ff0000',
|
|
secondary: '#00ff00',
|
|
accent: '#0000ff',
|
|
});
|
|
|
|
expect(configStore.getState().activeColors).toEqual({
|
|
primary: '#ff0000',
|
|
secondary: '#00ff00',
|
|
accent: '#0000ff',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('setPersonaAttire', () => {
|
|
it('should update persona attire', () => {
|
|
configStore.getState().setPersonaAttire('Emarati Kandura');
|
|
|
|
expect(configStore.getState().activePersonaAttire).toBe('Emarati Kandura');
|
|
});
|
|
});
|
|
|
|
describe('addPayload', () => {
|
|
it('should add a new payload', () => {
|
|
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
|
|
configStore.getState().addPayload(payload);
|
|
|
|
expect(configStore.getState().activePayloads).toHaveLength(1);
|
|
expect(configStore.getState().activePayloads[0]).toEqual(payload);
|
|
});
|
|
|
|
it('should not add duplicate payload by id', () => {
|
|
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
|
|
configStore.getState().addPayload(payload);
|
|
configStore.getState().addPayload(payload);
|
|
|
|
expect(configStore.getState().activePayloads).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('removePayload', () => {
|
|
it('should remove a payload by id', () => {
|
|
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
|
|
configStore.getState().addPayload(payload);
|
|
|
|
expect(configStore.getState().activePayloads).toHaveLength(1);
|
|
|
|
configStore.getState().removePayload('cam1');
|
|
|
|
expect(configStore.getState().activePayloads).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('updatePayload', () => {
|
|
it('should update an existing payload', () => {
|
|
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
|
|
configStore.getState().addPayload(payload);
|
|
configStore.getState().updatePayload('cam1', { position: 'chest' });
|
|
|
|
expect(configStore.getState().activePayloads[0].position).toBe('chest');
|
|
});
|
|
});
|
|
|
|
describe('clearPayloads', () => {
|
|
it('should remove all payloads', () => {
|
|
configStore.getState().addPayload({ id: 'cam1', type: 'camera', position: 'head' });
|
|
configStore.getState().addPayload({ id: 'cam2', type: 'camera', position: 'chest' });
|
|
|
|
expect(configStore.getState().activePayloads).toHaveLength(2);
|
|
|
|
configStore.getState().clearPayloads();
|
|
|
|
expect(configStore.getState().activePayloads).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('reset', () => {
|
|
it('should reset all state to defaults', () => {
|
|
configStore.getState().setColors({ primary: '#ff0000' });
|
|
configStore.getState().setPersonaAttire('Industrial Vest');
|
|
configStore.getState().setBody('edu');
|
|
configStore.getState().addPayload({ id: 'cam1', type: 'camera', position: 'head' });
|
|
configStore.getState().setHydrated(true);
|
|
|
|
configStore.getState().reset();
|
|
|
|
const state = configStore.getState();
|
|
expect(state.activeColors.primary).toBe('#96a2b6');
|
|
expect(state.activePersonaAttire).toBe('none');
|
|
expect(state.activeBody).toBe('basic');
|
|
expect(state.activePayloads).toEqual([]);
|
|
expect(state.isHydrated).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('hydrate', () => {
|
|
it('should hydrate state from partial config', () => {
|
|
configStore.getState().hydrate({
|
|
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
|
|
activePersonaAttire: 'Emarati Kandura',
|
|
});
|
|
|
|
const state = configStore.getState();
|
|
expect(state.activeColors.primary).toBe('#ff0000');
|
|
expect(state.activePersonaAttire).toBe('Emarati Kandura');
|
|
expect(state.isHydrated).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('serializeConfig & deserializeConfig', () => {
|
|
beforeEach(() => {
|
|
configStore.getState().reset();
|
|
});
|
|
|
|
it('should serialize state to base64', () => {
|
|
const state: ConfigState = {
|
|
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
|
|
activePersonaAttire: 'Test Attire',
|
|
activeBody: 'basic',
|
|
activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }],
|
|
isHydrated: true,
|
|
};
|
|
|
|
const encoded = serializeConfig(state);
|
|
expect(typeof encoded).toBe('string');
|
|
expect(encoded.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should deserialize base64 to state', () => {
|
|
const state: ConfigState = {
|
|
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
|
|
activePersonaAttire: 'Test Attire',
|
|
activeBody: 'basic',
|
|
activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }],
|
|
isHydrated: true,
|
|
};
|
|
|
|
const encoded = serializeConfig(state);
|
|
const decoded = deserializeConfig(encoded);
|
|
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.activeColors).toEqual(state.activeColors);
|
|
expect(decoded?.activePersonaAttire).toBe(state.activePersonaAttire);
|
|
expect(decoded?.activePayloads).toEqual(state.activePayloads);
|
|
});
|
|
|
|
it('should return null for invalid base64', () => {
|
|
const result = deserializeConfig('not-valid-base64!!!');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for invalid schema', () => {
|
|
const invalidJson = btoa(JSON.stringify({ invalid: 'schema' }));
|
|
const result = deserializeConfig(invalidJson);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should round-trip complex state correctly', () => {
|
|
const original: ConfigState = {
|
|
activeColors: { primary: '#aabbcc', secondary: '#ddeeff', accent: '#112233' },
|
|
activePersonaAttire: 'Emarati Kandura',
|
|
activeBody: 'edu',
|
|
activePayloads: [
|
|
{ id: 'cam1', type: 'PTZ Camera', position: 'head' },
|
|
{ id: 'light1', type: 'LED Array', position: 'chest' },
|
|
{ id: 'sensor1', type: 'Lidar', position: 'base' },
|
|
],
|
|
isHydrated: true,
|
|
};
|
|
|
|
const encoded = serializeConfig(original);
|
|
const decoded = deserializeConfig(encoded);
|
|
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.activeColors).toEqual(original.activeColors);
|
|
expect(decoded?.activePersonaAttire).toBe(original.activePersonaAttire);
|
|
expect(decoded?.activeBody).toBe(original.activeBody);
|
|
expect(decoded?.activePayloads).toHaveLength(3);
|
|
expect(decoded?.activePayloads).toEqual(original.activePayloads);
|
|
});
|
|
});
|