Add optional room areas
This commit is contained in:
parent
a6500e2b26
commit
dce31ee143
26 changed files with 3669 additions and 78 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ function createTestDatabase(): DatabaseContext {
|
|||
floorId: "floor-1",
|
||||
roomNumber: "001",
|
||||
roomName: "Technik",
|
||||
areaM2: 31.75,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
59
tests/room-area.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue