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); }); }); });