Covers DesignState (40 tests), HouseRenderer (19), InteractionManager (24), ThemeManager (8), ExportManager (11), and CatalogPanel (30). Uses vitest with THREE.js mocks for browser-free testing.
191 lines
6.5 KiB
JavaScript
191 lines
6.5 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ExportManager } from '../src/export.js';
|
|
import { DesignState } from '../src/state.js';
|
|
import { Vector2 } from '../tests/__mocks__/three.js';
|
|
|
|
function makeMockRenderer() {
|
|
return {
|
|
renderer: {
|
|
getSize: vi.fn((v) => v.set(800, 600)),
|
|
getPixelRatio: vi.fn(() => 1),
|
|
setSize: vi.fn(),
|
|
setPixelRatio: vi.fn(),
|
|
render: vi.fn(),
|
|
domElement: {
|
|
toDataURL: vi.fn(() => 'data:image/png;base64,mock')
|
|
}
|
|
},
|
|
camera: {
|
|
aspect: 800 / 600,
|
|
updateProjectionMatrix: vi.fn()
|
|
},
|
|
scene: {},
|
|
container: {
|
|
dispatchEvent: vi.fn()
|
|
},
|
|
houseData: {
|
|
floors: [{ id: 'eg', rooms: [{ id: 'r1', name: 'Room1' }] }]
|
|
},
|
|
currentFloor: 0,
|
|
designData: null,
|
|
_clearFloor: vi.fn(),
|
|
_renderRoom: vi.fn(),
|
|
_placeFurnitureForFloor: vi.fn()
|
|
};
|
|
}
|
|
|
|
function makeDesign() {
|
|
return {
|
|
name: 'Test Design',
|
|
rooms: [{ roomId: 'r1', furniture: [{ catalogId: 'c1', position: { x: 1, z: 2 }, rotation: 0 }] }]
|
|
};
|
|
}
|
|
|
|
describe('ExportManager', () => {
|
|
let exportMgr, state, mockRenderer;
|
|
|
|
beforeEach(() => {
|
|
// Stub DOM APIs
|
|
globalThis.URL = { createObjectURL: vi.fn(() => 'blob:url'), revokeObjectURL: vi.fn() };
|
|
globalThis.Blob = class Blob { constructor(parts, opts) { this.parts = parts; this.type = opts?.type; } };
|
|
globalThis.document = globalThis.document || {};
|
|
globalThis.document.createElement = (tag) => {
|
|
return { href: '', download: '', click: vi.fn(), type: '', accept: '', files: [], addEventListener: vi.fn() };
|
|
};
|
|
|
|
state = new DesignState(makeDesign());
|
|
mockRenderer = makeMockRenderer();
|
|
exportMgr = new ExportManager(mockRenderer, state);
|
|
});
|
|
|
|
describe('exportDesignJSON', () => {
|
|
it('creates a download with correct filename', () => {
|
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
|
globalThis.document.createElement = () => mockAnchor;
|
|
|
|
exportMgr.exportDesignJSON();
|
|
|
|
expect(mockAnchor.download).toBe('Test Design.json');
|
|
expect(mockAnchor.click).toHaveBeenCalled();
|
|
});
|
|
|
|
it('includes exportedAt timestamp', () => {
|
|
let blobContent = '';
|
|
globalThis.Blob = class {
|
|
constructor(parts) { blobContent = parts[0]; }
|
|
};
|
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
|
|
|
exportMgr.exportDesignJSON();
|
|
const data = JSON.parse(blobContent);
|
|
expect(data.exportedAt).toBeDefined();
|
|
expect(new Date(data.exportedAt).getTime()).not.toBeNaN();
|
|
});
|
|
|
|
it('exports current state data', () => {
|
|
let blobContent = '';
|
|
globalThis.Blob = class {
|
|
constructor(parts) { blobContent = parts[0]; }
|
|
};
|
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
|
|
|
exportMgr.exportDesignJSON();
|
|
const data = JSON.parse(blobContent);
|
|
expect(data.name).toBe('Test Design');
|
|
expect(data.rooms).toHaveLength(1);
|
|
expect(data.rooms[0].furniture[0].catalogId).toBe('c1');
|
|
});
|
|
|
|
it('uses "design" as fallback filename', () => {
|
|
// State with no name
|
|
state.loadDesign({ rooms: [] });
|
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
|
globalThis.document.createElement = () => mockAnchor;
|
|
|
|
exportMgr.exportDesignJSON();
|
|
expect(mockAnchor.download).toBe('design.json');
|
|
});
|
|
});
|
|
|
|
describe('_loadDesignFile', () => {
|
|
it('loads valid design file', async () => {
|
|
const design = { name: 'Loaded', rooms: [{ roomId: 'r2', furniture: [] }] };
|
|
const file = { text: () => Promise.resolve(JSON.stringify(design)), name: 'loaded.json' };
|
|
|
|
await exportMgr._loadDesignFile(file);
|
|
|
|
expect(state.design.name).toBe('Loaded');
|
|
expect(state.design.rooms).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects file without rooms array', async () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const file = { text: () => Promise.resolve('{"name": "bad"}'), name: 'bad.json' };
|
|
|
|
await exportMgr._loadDesignFile(file);
|
|
|
|
expect(mockRenderer.container.dispatchEvent).toHaveBeenCalled();
|
|
const event = mockRenderer.container.dispatchEvent.mock.calls.find(
|
|
c => c[0].type === 'loaderror'
|
|
);
|
|
expect(event).toBeDefined();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('rejects invalid JSON', async () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const file = { text: () => Promise.resolve('not json'), name: 'bad.json' };
|
|
|
|
await exportMgr._loadDesignFile(file);
|
|
|
|
expect(mockRenderer.container.dispatchEvent).toHaveBeenCalled();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('dispatches designloaded event on success', async () => {
|
|
const design = { name: 'Success', rooms: [{ roomId: 'r1', furniture: [] }] };
|
|
const file = { text: () => Promise.resolve(JSON.stringify(design)), name: 'test.json' };
|
|
|
|
await exportMgr._loadDesignFile(file);
|
|
|
|
const event = mockRenderer.container.dispatchEvent.mock.calls.find(
|
|
c => c[0].type === 'designloaded'
|
|
);
|
|
expect(event).toBeDefined();
|
|
expect(event[0].detail.name).toBe('Success');
|
|
});
|
|
});
|
|
|
|
describe('exportScreenshot', () => {
|
|
it('renders at requested resolution', () => {
|
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
|
globalThis.document.createElement = () => mockAnchor;
|
|
|
|
exportMgr.exportScreenshot(1920, 1080);
|
|
|
|
expect(mockRenderer.renderer.setSize).toHaveBeenCalledWith(1920, 1080);
|
|
expect(mockRenderer.renderer.render).toHaveBeenCalled();
|
|
expect(mockAnchor.download).toBe('house-design.png');
|
|
});
|
|
|
|
it('restores original renderer size after capture', () => {
|
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
|
globalThis.document.createElement = () => mockAnchor;
|
|
|
|
exportMgr.exportScreenshot();
|
|
|
|
// setSize called twice: once for capture, once to restore
|
|
expect(mockRenderer.renderer.setSize).toHaveBeenCalledTimes(2);
|
|
// Last call restores original 800x600
|
|
const lastCall = mockRenderer.renderer.setSize.mock.calls[1];
|
|
expect(lastCall).toEqual([800, 600]);
|
|
});
|
|
|
|
it('updates camera projection matrix twice', () => {
|
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
|
exportMgr.exportScreenshot();
|
|
expect(mockRenderer.camera.updateProjectionMatrix).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|