Add optional room areas

This commit is contained in:
Julian Appel 2026-09-27 20:00:03 +02:00
parent a6500e2b26
commit dce31ee143
26 changed files with 3669 additions and 78 deletions

View file

@ -21,6 +21,7 @@ import {
normalizeBuildingSelection,
normalizeFloorSelection,
showsRoomsWithoutFloorEntry,
summarizeRoomAreas,
} from "../src/frontend/utils/building-structure.js";
function building(id: string, name: string, sortOrder: number): BuildingDto {
@ -36,13 +37,19 @@ function floor(
return { id, projectId: "project-1", name, sortOrder, buildingId };
}
function room(id: string, floorId: string | null, roomNumber = id): RoomDto {
function room(
id: string,
floorId: string | null,
roomNumber = id,
areaM2: number | null = null
): RoomDto {
return {
id,
projectId: "project-1",
floorId,
roomNumber,
roomName: `Raum ${roomNumber}`,
areaM2,
};
}
@ -263,6 +270,43 @@ describe("building structure view", () => {
);
});
it("sums known areas and counts missing areas per floor and building", () => {
const areaRooms = [
room("a", "f-a-eg", "a", 20.25),
room("b", "f-a-eg"),
room("c", "f-free", "c", 5.5),
room("d", null, "d", 3),
room("e", "f-deleted"),
];
const summaries = summarizeRoomAreas(areaRooms, floors, buildings);
assert.deepEqual(summaries.total, {
areaM2: 28.75,
roomCount: 5,
missingAreaCount: 2,
});
assert.deepEqual(summaries.byFloor.get("f-a-eg"), {
areaM2: 20.25,
roomCount: 2,
missingAreaCount: 1,
});
assert.deepEqual(summaries.byFloor.get(null), {
areaM2: 3,
roomCount: 2,
missingAreaCount: 1,
});
assert.deepEqual(summaries.byBuilding.get("b-a"), {
areaM2: 20.25,
roomCount: 2,
missingAreaCount: 1,
});
assert.deepEqual(summaries.byBuilding.get(null), {
areaM2: 8.5,
roomCount: 3,
missingAreaCount: 1,
});
});
it("describes a room location with floor and building", () => {
assert.equal(
describeRoomLocation(room("r", "f-a-og"), floors, buildings),

View file

@ -28,10 +28,13 @@ it("upgrades a populated pre-print database through the current schema without c
VALUES ('b', 'p', 'Existing board', 0.7);
INSERT INTO floors (id, project_id, name, sort_order)
VALUES ('f', 'p', 'Existing floor', 3);
INSERT INTO rooms (id, project_id, floor_id, room_number, room_name)
VALUES ('r', 'p', 'f', '101', 'Existing room');
`);
const oldProject = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
const oldBoard = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
const oldFloor = context.sqlite.prepare("SELECT * FROM floors").get() as Record<string, unknown>;
const oldRoom = context.sqlite.prepare("SELECT * FROM rooms").get() as Record<string, unknown>;
const backup = path.join(staging, "before-print.db");
files.push(backup);
await context.sqlite.backup(backup);
@ -40,12 +43,15 @@ it("upgrades a populated pre-print database through the current schema without c
const project = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
const board = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
const floor = context.sqlite.prepare("SELECT * FROM floors").get() as Record<string, unknown>;
const room = context.sqlite.prepare("SELECT * FROM rooms").get() as Record<string, unknown>;
for (const [key, value] of Object.entries(oldProject)) assert.deepEqual(project[key], value);
for (const [key, value] of Object.entries(oldBoard)) assert.deepEqual(board[key], value);
for (const [key, value] of Object.entries(oldFloor)) assert.deepEqual(floor[key], value);
for (const [key, value] of Object.entries(oldRoom)) assert.deepEqual(room[key], value);
for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null);
for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null);
assert.equal(floor.building_id, null);
assert.equal(room.area_m2, null);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);

View file

@ -22,6 +22,11 @@ import {
createProjectRoomInsertProjectCommand,
createProjectRoomSnapshot,
createProjectRoomUpdateProjectCommand,
projectRoomCommandSchemaVersion,
projectRoomDeleteCommandType,
projectRoomUpdateCommandType,
type LegacyProjectRoomDeleteProjectCommand,
type LegacyProjectRoomUpdateProjectCommand,
} from "../src/domain/models/project-location-structure-project-command.model.js";
import {
createFloor,
@ -66,10 +71,12 @@ describe("project-location structure project command", () => {
floorId: floor.id,
roomNumber: " 001 ",
roomName: " Technik ",
areaM2: 23.456,
});
assert.equal(floor.name, "EG");
assert.equal(room.roomNumber, "001");
assert.equal(room.roomName, "Technik");
assert.equal(room.areaM2, 23.456);
assert.throws(
() =>
createProjectFloorInsertProjectCommand({
@ -113,6 +120,7 @@ describe("project-location structure project command", () => {
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
areaM2: 23.456,
},
8
);
@ -120,7 +128,7 @@ describe("project-location structure project command", () => {
await updateRoom(
"project-1",
"room-1",
{ roomNumber: "101", roomName: "Büro" },
{ roomNumber: "101", roomName: "Büro", areaM2: null },
10
);
await deleteFloor("project-1", "floor-1", 11);
@ -141,6 +149,7 @@ describe("project-location structure project command", () => {
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
areaM2: 23.456,
expectedRevision: 8,
},
},
@ -155,6 +164,7 @@ describe("project-location structure project command", () => {
body: {
roomNumber: "101",
roomName: "Büro",
areaM2: null,
floorId: null,
expectedRevision: 10,
},
@ -199,6 +209,7 @@ describe("project-location structure project command", () => {
floorId: null,
roomNumber: "002",
roomName: "Lager",
areaM2: 48.125,
};
const roomCommand = createProjectRoomUpdateProjectCommand(room, updatedRoom);
const roomResult = repository.execute({
@ -427,6 +438,64 @@ describe("project-location structure project command", () => {
}
});
it("upgrades version-1 room commands without overwriting a newer area", () => {
const context = createTestDatabase();
try {
const repository = new ProjectLocationStructureProjectCommandRepository(context.db);
const history = new ProjectHistoryRepository(context.db);
const room = createProjectRoomSnapshot("project-1", {
roomNumber: "001",
roomName: "Technik",
});
context.db.insert(rooms).values(room).run();
const { areaM2: _expectedArea, ...legacyExpected } = room;
const legacyTarget = { ...legacyExpected, roomName: "Technik alt" };
const legacyUpdate: LegacyProjectRoomUpdateProjectCommand = {
schemaVersion: 1,
type: projectRoomUpdateCommandType,
payload: { expected: legacyExpected, target: legacyTarget },
};
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: legacyUpdate,
});
assert.equal(updated.inverse.schemaVersion, projectRoomCommandSchemaVersion);
assert.equal(context.db.select().from(rooms).get()?.areaM2, null);
const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undo.changeSetId,
command: updated.inverse,
});
context.db.update(rooms).set({ areaM2: 12.5 }).where(eq(rooms.id, room.id)).run();
const legacyDelete: LegacyProjectRoomDeleteProjectCommand = {
schemaVersion: 1,
type: projectRoomDeleteCommandType,
payload: { room: legacyExpected },
};
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: legacyDelete,
}),
/changed before deletion/
);
assert.equal(context.db.select().from(rooms).get()?.areaM2, 12.5);
} finally {
context.close();
}
});
it("rejects foreign, stale and changed locations without partial writes", () => {
const context = createTestDatabase();
try {

View file

@ -142,6 +142,7 @@ function createTestDatabase(): DatabaseContext {
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
areaM2: 31.75,
})
.run();
context.db
@ -461,12 +462,16 @@ describe("project snapshot repository", () => {
distributionBoards: Array<Record<string, unknown>>;
buildings?: unknown;
floors: Array<Record<string, unknown>>;
rooms: Array<Record<string, unknown>>;
};
payload.schemaVersion = 5;
delete payload.buildings;
for (const floor of payload.floors) {
delete floor.buildingId;
}
for (const room of payload.rooms) {
delete room.areaM2;
}
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
delete payload.project[field];
}
@ -597,6 +602,7 @@ describe("portable project transfer", () => {
copied.state.circuits[0].deviceRows[0].roomId,
copied.state.rooms[0].id
);
assert.equal(copied.state.rooms[0].areaM2, 31.75);
assert.equal(
copied.state.distributionBoards[0].floorId,
copied.state.floors[0].id

View file

@ -96,6 +96,7 @@ function createTestDatabase(): DatabaseContext {
floorId: "floor-1",
roomNumber: "001",
roomName: "Technik",
areaM2: 31.75,
})
.run();
context.db

View file

@ -91,7 +91,14 @@ function minimalSnapshot() {
sortOrder: number;
buildingId: string | null;
}>,
rooms: [],
rooms: [] as Array<{
id: string;
projectId: string;
floorId: string | null;
roomNumber: string;
roomName: string;
areaM2: number | null;
}>,
};
}
@ -443,6 +450,26 @@ describe("project state snapshot model", () => {
assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField));
});
it("keeps room areas and upgrades snapshot version 7 with an empty area", () => {
const current = minimalSnapshot();
current.rooms.push({
id: "room-1",
projectId: "project-1",
floorId: null,
roomNumber: "001",
roomName: "Technik",
areaM2: 23.456,
});
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
const previous = structuredClone(current);
previous.schemaVersion = 7 as typeof previous.schemaVersion;
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.rooms[0]?.areaM2, null);
});
it("upgrades snapshot version 6 without buildings", () => {
const snapshot = minimalSnapshot();
snapshot.floors.push({

View file

@ -151,7 +151,7 @@ describe("Revit CSV modal presentation", () => {
objects: [],
existing: {
floors: [{ id: "floor-1", name: "EG", sortOrder: 0 }],
rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro" }],
rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro", areaM2: null }],
distributionBoards: [{ id: "board-1", name: "UV-01", floorId: "floor-1" }],
projectDevices: [{ id: "device-1", name: "Steckdose", displayName: "Steckdose", category: "single_phase", connectionKind: null, powerPerUnit: 100 }],
},

59
tests/room-area.test.ts Normal file
View file

@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RoomModal } from "../src/frontend/components/room-modal.js";
import {
formatRoomArea,
parseRoomAreaInput,
} from "../src/frontend/utils/room-area.js";
import {
createRoomSchema,
updateRoomSchema,
} from "../src/shared/validation/project-structure.schemas.js";
describe("room area input", () => {
it("parses optional German decimals without rounding", () => {
assert.deepEqual(parseRoomAreaInput(""), { ok: true, value: null });
assert.deepEqual(parseRoomAreaInput(" 23,456 "), { ok: true, value: 23.456 });
assert.deepEqual(parseRoomAreaInput(",5"), { ok: true, value: 0.5 });
assert.deepEqual(parseRoomAreaInput("0"), { ok: true, value: 0 });
for (const value of ["-1", "23.45", "abc", "1,2,3"]) {
assert.equal(parseRoomAreaInput(value).ok, false, value);
}
assert.equal(formatRoomArea(23.456), "23,46");
});
it("validates finite non-negative API values and remains backward compatible", () => {
const base = {
expectedRevision: 1,
roomNumber: "101",
roomName: "Büro",
};
assert.equal(createRoomSchema.safeParse({ ...base, areaM2: 23.456 }).success, true);
assert.equal(createRoomSchema.safeParse({ ...base, areaM2: null }).success, true);
assert.equal(createRoomSchema.safeParse(base).success, true);
assert.equal(updateRoomSchema.safeParse({ ...base, floorId: null }).success, true);
assert.equal(createRoomSchema.safeParse({ ...base, areaM2: -1 }).success, false);
assert.equal(createRoomSchema.safeParse({ ...base, areaM2: Number.NaN }).success, false);
});
it("renders the optional area with two decimals in the shared room dialog", () => {
const markup = renderToStaticMarkup(
createElement(RoomModal, {
floors: [],
initialValues: {
roomNumber: "101",
roomName: "Büro",
areaM2: 23.4,
},
isSaving: false,
onClose: () => undefined,
onSave: async () => undefined,
})
);
assert.match(markup, /Fläche \[m²\]/);
assert.match(markup, /value="23,40"/);
assert.match(markup, /Netto-Raumfläche/);
});
});