Files
house-design/tests/floorplan-import.test.js
m d53686ed65 Add floor plan image import via LLM vision APIs
New FloorplanImporter module that accepts floor plan images (architect
drawings, realtor photos, hand sketches) and uses Claude or OpenAI vision
APIs to convert them into the project's house JSON format for immediate
3D viewing.

Features:
- Drag-and-drop image upload with preview
- Multi-provider support (Claude Sonnet, GPT-4o)
- Canvas preprocessing (resize to 2048px max)
- Structured prompt engineering for accurate room extraction
- JSON validation and auto-repair of common LLM output issues
- Result preview showing rooms, doors, windows counts
- Inline JSON editor for manual corrections
- API key persistence in localStorage
- Integrated into sidebar File section as "Import Floor Plan" button
2026-02-07 16:38:14 +01:00

293 lines
9.3 KiB
JavaScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FloorplanImporter } from '../src/floorplan-import.js';
// Minimal renderer mock
function makeRenderer() {
const listeners = {};
return {
houseData: null,
currentFloor: 0,
container: {
dispatchEvent: vi.fn(),
addEventListener: (type, fn) => {
listeners[type] = listeners[type] || [];
listeners[type].push(fn);
},
_listeners: listeners
},
_clearFloor: vi.fn(),
_renderRoom: vi.fn(),
showFloor: vi.fn()
};
}
// Valid house data for testing
function makeSampleHouse() {
return {
name: 'Test House',
description: 'A test house',
units: 'meters',
building: {
footprint: { width: 10, depth: 8 },
wallThickness: 0.24,
roofType: 'gable'
},
floors: [{
id: 'eg',
name: 'Ground Floor',
nameEN: 'Ground Floor',
level: 0,
ceilingHeight: 2.6,
rooms: [
{
id: 'eg-living',
name: 'Living Room',
nameEN: 'Living Room',
type: 'living',
position: { x: 0, y: 0 },
dimensions: { width: 5, length: 4 },
flooring: 'hardwood',
walls: {
south: { type: 'exterior', doors: [], windows: [] },
north: { type: 'interior', doors: [{ id: 'eg-living-d1', type: 'interior', position: 1, width: 0.9, height: 2.1, connectsTo: 'eg-kitchen' }], windows: [] },
east: { type: 'interior', doors: [], windows: [] },
west: { type: 'exterior', doors: [], windows: [{ id: 'eg-living-w1', type: 'casement', position: 1.5, width: 1.2, height: 1.2, sillHeight: 0.9 }] }
}
},
{
id: 'eg-kitchen',
name: 'Kitchen',
nameEN: 'Kitchen',
type: 'kitchen',
position: { x: 0, y: 4 },
dimensions: { width: 5, length: 4 },
flooring: 'tile',
walls: {
south: { type: 'interior', doors: [], windows: [] },
north: { type: 'exterior', doors: [], windows: [] },
east: { type: 'interior', doors: [], windows: [] },
west: { type: 'exterior', doors: [], windows: [] }
}
}
]
}]
};
}
describe('FloorplanImporter', () => {
let renderer;
let importer;
beforeEach(() => {
renderer = makeRenderer();
importer = new FloorplanImporter(renderer);
});
describe('_validateHouseJSON', () => {
it('validates correct house data', () => {
const result = importer._validateHouseJSON(makeSampleHouse());
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('catches missing name', () => {
const data = makeSampleHouse();
delete data.name;
const result = importer._validateHouseJSON(data);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing building name');
});
it('catches missing building footprint', () => {
const data = makeSampleHouse();
delete data.building.footprint;
const result = importer._validateHouseJSON(data);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing building footprint');
});
it('catches missing floors', () => {
const data = makeSampleHouse();
data.floors = [];
const result = importer._validateHouseJSON(data);
expect(result.valid).toBe(false);
expect(result.errors).toContain('No floors found');
});
it('catches rooms with missing walls', () => {
const data = makeSampleHouse();
delete data.floors[0].rooms[0].walls;
const result = importer._validateHouseJSON(data);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Room "eg-living" missing walls');
});
it('catches invalid wall type', () => {
const data = makeSampleHouse();
data.floors[0].rooms[0].walls.south.type = 'concrete';
const result = importer._validateHouseJSON(data);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('invalid type'))).toBe(true);
});
});
describe('_autoRepair', () => {
it('adds missing name', () => {
const data = { floors: [{ rooms: [] }] };
const repaired = importer._autoRepair(data);
expect(repaired.name).toBe('Imported Floor Plan');
});
it('adds missing units', () => {
const data = { floors: [] };
const repaired = importer._autoRepair(data);
expect(repaired.units).toBe('meters');
});
it('converts string numbers to floats', () => {
const data = {
floors: [{
rooms: [{
id: 'test',
position: { x: '3.5', y: '2.0' },
dimensions: { width: '4', length: '5.5' },
walls: {
north: { type: 'exterior', doors: [], windows: [] },
south: { type: 'exterior', doors: [], windows: [] },
east: { type: 'interior', doors: [], windows: [] },
west: { type: 'interior', doors: [], windows: [] }
}
}]
}]
};
const repaired = importer._autoRepair(data);
const room = repaired.floors[0].rooms[0];
expect(room.position.x).toBe(3.5);
expect(room.position.y).toBe(2.0);
expect(room.dimensions.width).toBe(4);
expect(room.dimensions.length).toBe(5.5);
});
it('generates missing IDs', () => {
const data = {
floors: [{
id: 'eg',
rooms: [{
nameEN: 'Living Room',
position: { x: 0, y: 0 },
dimensions: { width: 4, length: 4 },
walls: {}
}]
}]
};
const repaired = importer._autoRepair(data);
expect(repaired.floors[0].rooms[0].id).toBe('eg-living-room');
});
it('infers flooring from room type', () => {
const data = {
floors: [{
id: 'eg',
rooms: [
{ id: 'eg-k', type: 'kitchen', position: { x: 0, y: 0 }, dimensions: { width: 3, length: 3 }, walls: {} },
{ id: 'eg-b', type: 'bedroom', position: { x: 3, y: 0 }, dimensions: { width: 3, length: 3 }, walls: {} }
]
}]
};
const repaired = importer._autoRepair(data);
expect(repaired.floors[0].rooms[0].flooring).toBe('tile');
expect(repaired.floors[0].rooms[1].flooring).toBe('hardwood');
});
it('adds missing walls', () => {
const data = {
floors: [{
rooms: [{
id: 'test',
position: { x: 0, y: 0 },
dimensions: { width: 3, length: 3 }
}]
}]
};
const repaired = importer._autoRepair(data);
const room = repaired.floors[0].rooms[0];
expect(room.walls.north).toBeDefined();
expect(room.walls.south).toBeDefined();
expect(room.walls.east).toBeDefined();
expect(room.walls.west).toBeDefined();
expect(room.walls.north.type).toBe('interior');
});
it('computes footprint from rooms when missing', () => {
const data = {
floors: [{
rooms: [
{ id: 'r1', position: { x: 0, y: 0 }, dimensions: { width: 5, length: 4 }, walls: {} },
{ id: 'r2', position: { x: 5, y: 0 }, dimensions: { width: 3, length: 4 }, walls: {} }
]
}]
};
const repaired = importer._autoRepair(data);
expect(repaired.building.footprint.width).toBe(8);
expect(repaired.building.footprint.depth).toBe(4);
});
});
describe('_applyToRenderer', () => {
it('sets house data on renderer', () => {
const data = makeSampleHouse();
importer._applyToRenderer(data);
expect(renderer.houseData).toBe(data);
expect(renderer.currentFloor).toBe(0);
});
it('clears and renders floor', () => {
const data = makeSampleHouse();
importer._applyToRenderer(data);
expect(renderer._clearFloor).toHaveBeenCalled();
expect(renderer._renderRoom).toHaveBeenCalledTimes(2);
});
it('dispatches houseloaded event', () => {
const data = makeSampleHouse();
importer._applyToRenderer(data);
expect(renderer.container.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'houseloaded',
detail: { name: 'Test House', floors: 1 }
})
);
});
it('calls onHouseLoaded callback', () => {
const callback = vi.fn();
const importerWithCb = new FloorplanImporter(renderer, { onHouseLoaded: callback });
const data = makeSampleHouse();
importerWithCb._applyToRenderer(data);
expect(callback).toHaveBeenCalledWith(data);
});
});
describe('_buildPrompt', () => {
it('includes building name', () => {
const prompt = importer._buildPrompt('My House', 1, '');
expect(prompt).toContain('My House');
});
it('includes scale hint when provided', () => {
const prompt = importer._buildPrompt('House', 1, 'Living room is 5m wide');
expect(prompt).toContain('Scale reference: Living room is 5m wide');
});
it('uses default scale message when no hint', () => {
const prompt = importer._buildPrompt('House', 1, '');
expect(prompt).toContain('Estimate dimensions from standard door widths');
});
it('includes floor count', () => {
const prompt = importer._buildPrompt('House', 2, '');
expect(prompt).toContain('2 floor(s)');
});
});
});