Merge remote-tracking branch 'origin/main' into feature/cable-sizing

This commit is contained in:
Julian Appel 2026-09-27 16:54:12 +02:00
commit 598003554a
136 changed files with 27182 additions and 1669 deletions

View file

@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createBuilding,
createFloor,
deleteBuilding,
listBuildings,
updateBuilding,
updateFloor,
} from "../src/frontend/utils/api.js";
describe("building structure frontend API", () => {
it("sends building commands and optional floor building assignments", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
});
return new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await listBuildings("project-1");
await createBuilding("project-1", { name: "Haus A" }, 3);
await updateBuilding("project-1", "building-1", { name: "Haus B" }, 4);
await deleteBuilding("project-1", "building-1", 5);
await createFloor(
"project-1",
{ name: "EG", buildingId: "building-1" },
6
);
await createFloor("project-1", { name: "Dach", buildingId: null }, 7);
await createFloor("project-1", { name: "UG", buildingId: undefined }, 8);
await updateFloor(
"project-1",
"floor-1",
{ name: "1. OG", buildingId: null },
9
);
await updateFloor("project-1", "floor-1", { name: "2. OG" }, 10);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/buildings",
method: "GET",
body: undefined,
},
{
url: "/api/projects/project-1/buildings",
method: "POST",
body: { name: "Haus A", expectedRevision: 3 },
},
{
url: "/api/projects/project-1/buildings/building-1",
method: "PUT",
body: { name: "Haus B", expectedRevision: 4 },
},
{
url: "/api/projects/project-1/buildings/building-1",
method: "DELETE",
body: { expectedRevision: 5 },
},
{
url: "/api/projects/project-1/floors",
method: "POST",
body: { name: "EG", buildingId: "building-1", expectedRevision: 6 },
},
{
url: "/api/projects/project-1/floors",
method: "POST",
body: { name: "Dach", buildingId: null, expectedRevision: 7 },
},
{
url: "/api/projects/project-1/floors",
method: "POST",
body: { name: "UG", expectedRevision: 8 },
},
{
url: "/api/projects/project-1/floors/floor-1",
method: "PUT",
body: { name: "1. OG", buildingId: null, expectedRevision: 9 },
},
{
url: "/api/projects/project-1/floors/floor-1",
method: "PUT",
body: { name: "2. OG", expectedRevision: 10 },
},
]);
const updateWithoutBuilding = requests[8].body as Record<string, unknown>;
assert.equal("buildingId" in updateWithoutBuilding, false);
});
});

View file

@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type {
BuildingDto,
FloorDto,
RoomDto,
} from "../src/frontend/types.js";
import {
ALL_SELECTION,
UNASSIGNED_SELECTION,
countFloorsByBuilding,
countRoomsByFloor,
defaultBuildingIdForNewFloor,
defaultFloorIdForNewRoom,
describeRoomLocation,
filterFloorsByBuilding,
filterRooms,
groupFloorsByBuilding,
hasFloorsWithoutBuilding,
hasRoomsWithoutFloor,
normalizeBuildingSelection,
normalizeFloorSelection,
showsRoomsWithoutFloorEntry,
} from "../src/frontend/utils/building-structure.js";
function building(id: string, name: string, sortOrder: number): BuildingDto {
return { id, projectId: "project-1", name, sortOrder };
}
function floor(
id: string,
name: string,
buildingId: string | null,
sortOrder = 0
): FloorDto {
return { id, projectId: "project-1", name, sortOrder, buildingId };
}
function room(id: string, floorId: string | null, roomNumber = id): RoomDto {
return {
id,
projectId: "project-1",
floorId,
roomNumber,
roomName: `Raum ${roomNumber}`,
};
}
const buildings = [building("b-a", "Haus A", 0), building("b-b", "Haus B", 1)];
const floors = [
floor("f-free", "Außenanlage", null, 0),
floor("f-a-eg", "EG", "b-a", 1),
floor("f-b-eg", "EG", "b-b", 2),
floor("f-a-og", "1. OG", "b-a", 3),
floor("f-orphan", "Keller", "b-deleted", 4),
];
const rooms = [
room("r-a-eg", "f-a-eg"),
room("r-a-og", "f-a-og"),
room("r-b-eg", "f-b-eg"),
room("r-free", "f-free"),
room("r-orphan-floor", "f-orphan"),
room("r-none", null),
room("r-missing-floor", "f-deleted"),
];
const ids = (entries: ReadonlyArray<{ id: string }>) =>
entries.map((entry) => entry.id);
describe("building structure view", () => {
it("groups floors in building order and puts unassigned floors last", () => {
const groups = groupFloorsByBuilding(floors, buildings);
assert.deepEqual(
groups.map((group) => ({
buildingId: group.buildingId,
label: group.label,
floors: ids(group.floors),
})),
[
{ buildingId: "b-a", label: "Haus A", floors: ["f-a-eg", "f-a-og"] },
{ buildingId: "b-b", label: "Haus B", floors: ["f-b-eg"] },
{
buildingId: null,
label: "Ohne Gebäude",
floors: ["f-free", "f-orphan"],
},
]
);
});
it("omits empty groups", () => {
const groups = groupFloorsByBuilding(
[floor("f-1", "EG", "b-b")],
buildings
);
assert.deepEqual(
groups.map((group) => group.buildingId),
["b-b"]
);
assert.deepEqual(groupFloorsByBuilding([], buildings), []);
});
it("filters floors by building selection", () => {
assert.deepEqual(
ids(filterFloorsByBuilding(floors, buildings, ALL_SELECTION)),
ids(floors)
);
assert.deepEqual(
ids(filterFloorsByBuilding(floors, buildings, "b-a")),
["f-a-eg", "f-a-og"]
);
assert.deepEqual(
ids(filterFloorsByBuilding(floors, buildings, UNASSIGNED_SELECTION)),
["f-free", "f-orphan"]
);
});
it("detects floors without building and rooms without floor", () => {
assert.equal(hasFloorsWithoutBuilding(floors, buildings), true);
assert.equal(
hasFloorsWithoutBuilding([floor("f-1", "EG", "b-a")], buildings),
false
);
assert.equal(hasRoomsWithoutFloor(rooms, floors), true);
assert.equal(hasRoomsWithoutFloor([room("r-1", "f-a-eg")], floors), false);
});
it("filters rooms for building and floor selections", () => {
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, ALL_SELECTION, ALL_SELECTION)),
ids(rooms)
);
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, "b-a", ALL_SELECTION)),
["r-a-eg", "r-a-og"]
);
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, "b-a", "f-a-og")),
["r-a-og"]
);
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, ALL_SELECTION, "f-b-eg")),
["r-b-eg"]
);
assert.deepEqual(
ids(
filterRooms(
rooms,
floors,
buildings,
UNASSIGNED_SELECTION,
ALL_SELECTION
)
),
["r-free", "r-orphan-floor", "r-none", "r-missing-floor"]
);
assert.deepEqual(
ids(
filterRooms(
rooms,
floors,
buildings,
ALL_SELECTION,
UNASSIGNED_SELECTION
)
),
["r-none", "r-missing-floor"]
);
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, "b-a", UNASSIGNED_SELECTION)),
[]
);
});
it("offers 'Ohne Etage' only where rooms without floor can belong", () => {
assert.equal(showsRoomsWithoutFloorEntry(rooms, floors, ALL_SELECTION), true);
assert.equal(
showsRoomsWithoutFloorEntry(rooms, floors, UNASSIGNED_SELECTION),
true
);
assert.equal(showsRoomsWithoutFloorEntry(rooms, floors, "b-a"), false);
assert.equal(
showsRoomsWithoutFloorEntry([room("r-1", "f-a-eg")], floors, ALL_SELECTION),
false
);
});
it("falls back to 'Alle' for vanished or hidden selections", () => {
assert.equal(normalizeBuildingSelection("b-a", floors, buildings), "b-a");
assert.equal(
normalizeBuildingSelection("b-deleted", floors, buildings),
ALL_SELECTION
);
assert.equal(
normalizeBuildingSelection(UNASSIGNED_SELECTION, floors, buildings),
UNASSIGNED_SELECTION
);
assert.equal(
normalizeBuildingSelection(
UNASSIGNED_SELECTION,
[floor("f-1", "EG", "b-a")],
buildings
),
ALL_SELECTION
);
assert.equal(
normalizeFloorSelection("f-a-og", rooms, floors, buildings, "b-a"),
"f-a-og"
);
assert.equal(
normalizeFloorSelection("f-b-eg", rooms, floors, buildings, "b-a"),
ALL_SELECTION
);
assert.equal(
normalizeFloorSelection("f-deleted", rooms, floors, buildings, ALL_SELECTION),
ALL_SELECTION
);
assert.equal(
normalizeFloorSelection(
UNASSIGNED_SELECTION,
rooms,
floors,
buildings,
ALL_SELECTION
),
UNASSIGNED_SELECTION
);
assert.equal(
normalizeFloorSelection(UNASSIGNED_SELECTION, rooms, floors, buildings, "b-a"),
ALL_SELECTION
);
});
it("preselects the selected building and floor for new entries", () => {
assert.equal(defaultBuildingIdForNewFloor("b-a"), "b-a");
assert.equal(defaultBuildingIdForNewFloor(ALL_SELECTION), "");
assert.equal(defaultBuildingIdForNewFloor(UNASSIGNED_SELECTION), "");
assert.equal(defaultFloorIdForNewRoom("f-a-eg"), "f-a-eg");
assert.equal(defaultFloorIdForNewRoom(ALL_SELECTION), "");
assert.equal(defaultFloorIdForNewRoom(UNASSIGNED_SELECTION), "");
});
it("counts floors per building and rooms per floor", () => {
assert.deepEqual(
[...countFloorsByBuilding(floors, buildings)],
[
[null, 2],
["b-a", 2],
["b-b", 1],
]
);
assert.deepEqual(
[...countRoomsByFloor(rooms, floors)],
[
["f-a-eg", 1],
["f-a-og", 1],
["f-b-eg", 1],
["f-free", 1],
["f-orphan", 1],
[null, 2],
]
);
});
it("describes a room location with floor and building", () => {
assert.equal(
describeRoomLocation(room("r", "f-a-og"), floors, buildings),
"1. OG · Haus A"
);
assert.equal(
describeRoomLocation(room("r", "f-free"), floors, buildings),
"Außenanlage"
);
assert.equal(
describeRoomLocation(room("r", "f-orphan"), floors, buildings),
"Keller"
);
assert.equal(
describeRoomLocation(room("r", null), floors, buildings),
"Ohne Etage"
);
});
});

45
tests/client-id.test.ts Normal file
View file

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { webcrypto } from "node:crypto";
import { describe, it } from "node:test";
import { z } from "zod";
import { createClientId } from "../src/frontend/utils/client-id.js";
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe("client id", () => {
it("uses crypto.randomUUID when the context provides it", () => {
const id = createClientId({
getRandomValues: () => {
throw new Error("fallback must not be used");
},
randomUUID: () => "11111111-2222-4333-8444-555555555555",
});
assert.equal(id, "11111111-2222-4333-8444-555555555555");
});
it("falls back to getRandomValues outside a secure context", () => {
const insecure = {
getRandomValues: webcrypto.getRandomValues.bind(webcrypto),
} as Pick<Crypto, "getRandomValues">;
const ids = new Set<string>();
for (let index = 0; index < 200; index += 1) {
const id = createClientId(insecure);
assert.match(id, uuidV4);
assert.equal(z.string().uuid().safeParse(id).success, true);
ids.add(id);
}
assert.equal(ids.size, 200);
});
it("sets version and variant bits regardless of the random bytes", () => {
const allOnes = {
getRandomValues: <T extends ArrayBufferView | null>(array: T) => {
if (array instanceof Uint8Array) {
array.fill(0xff);
}
return array;
},
} as Pick<Crypto, "getRandomValues">;
assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff");
});
});

View file

@ -387,6 +387,110 @@ describe("distribution-board structure project command", () => {
}
});
it("updates the info card fields with persisted undo", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV-EG",
{ floorId: null, supplyType: "AV" }
);
const insert = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(structure),
});
const readInfo = () =>
context.db
.select({
boardNumber: distributionBoards.boardNumber,
location: distributionBoards.location,
fedFrom: distributionBoards.fedFrom,
remark: distributionBoards.remark,
})
.from(distributionBoards)
.get();
assert.deepEqual(readInfo(), {
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
});
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createDistributionBoardUpdateProjectCommand(
structure.distributionBoard.id,
{
boardNumber: "UV 0.1",
location: "Raum 003 Technik",
fedFrom: "HAK",
}
),
});
assert.deepEqual(readInfo(), {
boardNumber: "UV 0.1",
location: "Raum 003 Technik",
fedFrom: "HAK",
remark: null,
});
assert.deepEqual(
forward.inverse.payload.changes.map((change) => change.field),
["boardNumber", "location", "fedFrom"]
);
// A board with info values is no longer the unchanged empty setup.
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: insert.inverse,
}),
/changed before deletion/
);
const undoStep = new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: forward.inverse,
});
assert.deepEqual(readInfo(), {
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
});
for (const value of ["", " UV 1", "x".repeat(51)]) {
assert.throws(
() =>
createDistributionBoardUpdateProjectCommand(
structure.distributionBoard.id,
{ boardNumber: value }
),
/boardNumber/
);
}
} finally {
context.close();
}
});
it("sends distribution-board writes to the revision-safe API", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;

View file

@ -192,6 +192,67 @@ describe("distribution-board subtree project command", () => {
}
});
it("copies board info values and accepts snapshots stored before they existed", () => {
const context = createTestDatabase();
try {
const board = createPopulatedBoard(context);
context.db
.update(distributionBoards)
.set({ boardNumber: "UV 1.1", location: "Technik" })
.where(eq(distributionBoards.id, board.id))
.run();
const repository =
new DistributionBoardSubtreeProjectCommandRepository(context.db);
const withInfo = repository.capture("project-1", board.id);
const clone = cloneDistributionBoardSubtree(withInfo, "UV 1 Kopie");
assert.equal(clone.distributionBoard.boardNumber, "UV 1.1");
assert.equal(clone.distributionBoard.location, "Technik");
context.db
.update(distributionBoards)
.set({ boardNumber: null, location: null })
.where(eq(distributionBoards.id, board.id))
.run();
const legacySnapshot = structuredClone(
repository.capture("project-1", board.id)
);
const legacyBoard = legacySnapshot.distributionBoard as Record<
string,
unknown
>;
delete legacyBoard.boardNumber;
delete legacyBoard.location;
delete legacyBoard.fedFrom;
delete legacyBoard.remark;
const deleted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Verteilung löschen",
command: createDistributionBoardDeleteSubtreeProjectCommand(
legacySnapshot
),
});
assert.throws(
() => repository.capture("project-1", board.id),
/not found/
);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: deleted.inverse,
});
assert.equal(
repository.capture("project-1", board.id).distributionBoard.fedFrom,
null
);
} finally {
context.close();
}
});
it("rejects changed deletion snapshots without partial writes", () => {
const context = createTestDatabase();
try {

View file

@ -0,0 +1,77 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { DocumentBrandingRepository } from "../src/db/repositories/document-branding.repository.js";
import { officeProfileFields } from "../src/shared/constants/project-cover.js";
import { updateOfficeProfileSchema } from "../src/shared/validation/document-branding.schemas.js";
function createRepository() {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
return { context, repository: new DocumentBrandingRepository(context.db) };
}
describe("document branding repository", () => {
it("stores, replaces and removes the office profile", () => {
const { context, repository } = createRepository();
try {
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: null,
officeAddress: null,
});
repository.setSettings(
updateOfficeProfileSchema.parse({
officeName: " kbp köhler beraten + planen GmbH ",
officeAddress: "Straße 1\n12345 Ort",
})
);
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: "kbp köhler beraten + planen GmbH",
officeAddress: "Straße 1\n12345 Ort",
});
repository.setSettings(
updateOfficeProfileSchema.parse({ officeName: "Büro", officeAddress: "" })
);
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: "Büro",
officeAddress: null,
});
assert.equal(
updateOfficeProfileSchema.safeParse({ officeName: "x".repeat(201), officeAddress: null })
.success,
false
);
} finally {
context.close();
}
});
it("replaces and deletes the logo", () => {
const { context, repository } = createRepository();
try {
repository.upsert({
key: "logo",
mimeType: "image/png",
fileName: "a.png",
data: Buffer.from([1, 2, 3]),
});
repository.upsert({
key: "logo",
mimeType: "image/svg+xml",
fileName: "b.svg",
data: Buffer.from("<svg/>"),
});
const logo = repository.get("logo");
assert.equal(logo?.mimeType, "image/svg+xml");
assert.equal(logo?.data.toString(), "<svg/>");
assert.equal(repository.delete("logo"), true);
assert.equal(repository.get("logo"), undefined);
} finally {
context.close();
}
});
});

View file

@ -0,0 +1,432 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { EstimateBalanceSnapshot } from "../src/shared/estimate-balance/estimate-balance.js";
import {
addEstimateGroup,
addEstimateRow,
createCopyName,
createEstimateRow,
estimateBalanceFingerprint,
moveEstimateGroup,
moveEstimateRow,
normalizeEstimateSortOrder,
removeEstimateGroup,
removeEstimateRow,
toSaveableEstimateBalance,
updateEstimateGroup,
updateEstimateRow,
validateEstimateBalance,
} from "../src/frontend/estimate-balance/estimate-balance-document.js";
import {
estimateNumberRanges,
formatAmpere,
formatDecimalInput,
formatDeltaPercent,
formatIsoDateGerman,
formatKw,
formatSignedKw,
getTodayIsoDate,
parseGermanDecimal,
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
import {
ApiRequestError,
createEstimateBalance,
deleteEstimateBalance,
getEstimateBalance,
getEstimateComparison,
isRevisionConflictError,
listEstimateBalances,
updateEstimateBalance,
} from "../src/frontend/utils/api.js";
function sequentialIds(prefix = "id") {
let next = 0;
return () => `${prefix}-${++next}`;
}
function sampleBalance(): EstimateBalanceSnapshot {
return {
id: "balance-1",
projectId: "project-1",
name: "Entwurfsplanung",
workStage: "LPH 3",
dateIso: "2026-09-26",
reserveAvPercent: 20,
reserveSvPercent: 10,
sortOrder: 0,
groups: [
{
id: "group-410",
costGroup: "410",
title: "Sanitärtechnik",
editor: null,
sortOrder: 0,
rows: [
{ ...createEstimateRow(0, () => "row-a"), name: "Hebeanlage", powerPerUnitW: 1200 },
{ ...createEstimateRow(1, () => "row-b"), name: "Durchlauferhitzer" },
],
},
{
id: "group-440",
costGroup: "440",
title: "Starkstromanlagen",
editor: "ELT",
sortOrder: 1,
rows: [],
},
],
};
}
describe("estimate balance number input", () => {
it("accepts a comma as decimal separator", () => {
assert.deepEqual(parseGermanDecimal("0,8"), { ok: true, value: 0.8 });
assert.deepEqual(parseGermanDecimal(" 1200 "), { ok: true, value: 1200 });
assert.deepEqual(parseGermanDecimal(",5"), { ok: true, value: 0.5 });
assert.deepEqual(parseGermanDecimal("3,"), { ok: true, value: 3 });
});
it("rejects dots, empty input and garbage", () => {
for (const text of ["0.8", "1.200", "", " ", "abc", "1,2,3", "1e3", "--1"]) {
assert.equal(parseGermanDecimal(text).ok, false, text);
}
const dot = parseGermanDecimal("0.8");
assert.equal(dot.ok === false && dot.error.includes("Komma"), true);
});
it("checks the server ranges of the document fields", () => {
assert.equal(parseGermanDecimal("-1", estimateNumberRanges.quantity).ok, false);
assert.equal(parseGermanDecimal("0", estimateNumberRanges.quantity).ok, true);
assert.equal(parseGermanDecimal("1,1", estimateNumberRanges.simultaneityFactor).ok, false);
assert.equal(parseGermanDecimal("1", estimateNumberRanges.simultaneityFactor).ok, true);
assert.equal(parseGermanDecimal("0", estimateNumberRanges.cosPhi).ok, false);
assert.equal(parseGermanDecimal("0,01", estimateNumberRanges.cosPhi).ok, true);
assert.equal(parseGermanDecimal("101", estimateNumberRanges.reservePercent).ok, false);
});
it("formats input text so it parses back unchanged", () => {
assert.equal(formatDecimalInput(1200), "1200");
assert.equal(formatDecimalInput(0.8), "0,8");
const parsed = parseGermanDecimal(formatDecimalInput(12345.678));
assert.deepEqual(parsed, { ok: true, value: 12345.678 });
});
});
describe("estimate balance display format", () => {
it("rounds half away from zero like Excel", () => {
assert.equal(formatKw(40.175), "40,18");
assert.equal(formatKw(30.24), "30,24");
assert.equal(formatKw(1234.5), "1.234,50");
assert.equal(formatKw(-0.001), "0,00");
assert.equal(formatAmpere(57.75), "57,75");
assert.equal(formatAmpere(235.749), "235,75");
});
it("shows signed deltas and percentages", () => {
assert.equal(formatSignedKw(1.5), "+1,50");
assert.equal(formatSignedKw(-1.825), "-1,83");
assert.equal(formatSignedKw(0.001), "0,00");
assert.equal(formatDeltaPercent(12.34), "+12,3 %");
assert.equal(formatDeltaPercent(-5), "-5,0 %");
assert.equal(formatDeltaPercent(null), "–");
});
it("formats dates", () => {
assert.equal(formatIsoDateGerman("2026-09-26"), "26.09.2026");
assert.equal(formatIsoDateGerman(null), "–");
assert.equal(getTodayIsoDate(new Date(2026, 0, 5, 23, 30)), "2026-01-05");
});
});
describe("estimate balance document editing", () => {
it("adds rows at the end or after a row with continuous sort orders", () => {
const createId = sequentialIds("new");
const appended = addEstimateRow(sampleBalance(), "group-410", { createId });
assert.equal(appended.rowId, "new-1");
assert.deepEqual(
appended.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-a", 0], ["row-b", 1], ["new-1", 2]]
);
const inserted = addEstimateRow(sampleBalance(), "group-410", {
afterRowId: "row-a",
createId,
});
assert.deepEqual(
inserted.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-a", 0], ["new-2", 1], ["row-b", 2]]
);
const unknownGroup = addEstimateRow(sampleBalance(), "missing", { createId });
assert.equal(unknownGroup.rowId, null);
});
it("creates rows with neutral defaults", () => {
const row = createEstimateRow(3, () => "row-x");
assert.deepEqual(row, {
id: "row-x",
sortOrder: 3,
supply: null,
name: "",
quantity: 1,
unit: "piece",
connection: null,
powerPerUnitW: 0,
simultaneityFactor: 1,
cosPhi: 1,
safetySupply: false,
remark: null,
});
});
it("updates, removes and moves rows without touching the input", () => {
const original = sampleBalance();
const updated = updateEstimateRow(original, "group-410", "row-b", {
quantity: 3,
safetySupply: true,
});
assert.equal(updated.groups[0]!.rows[1]!.quantity, 3);
assert.equal(updated.groups[0]!.rows[1]!.safetySupply, true);
assert.equal(original.groups[0]!.rows[1]!.quantity, 1);
const removed = removeEstimateRow(original, "group-410", "row-a");
assert.deepEqual(
removed.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-b", 0]]
);
const moved = moveEstimateRow(original, "group-410", "row-b", -1);
assert.deepEqual(
moved.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-b", 0], ["row-a", 1]]
);
assert.equal(moveEstimateRow(original, "group-410", "row-a", -1), original);
});
it("adds, edits, moves and removes groups", () => {
const added = addEstimateGroup(
sampleBalance(),
{ costGroup: "480", title: "Gebäudeautomation" },
() => "group-480"
);
assert.equal(added.groupId, "group-480");
assert.deepEqual(
added.balance.groups.map((group) => [group.id, group.sortOrder]),
[["group-410", 0], ["group-440", 1], ["group-480", 2]]
);
assert.equal(added.balance.groups[2]!.editor, null);
const edited = updateEstimateGroup(added.balance, "group-480", {
costGroup: "481",
editor: "MSR",
});
assert.equal(edited.groups[2]!.costGroup, "481");
assert.equal(edited.groups[2]!.title, "Gebäudeautomation");
const movedUp = moveEstimateGroup(edited, "group-480", -1);
assert.deepEqual(
movedUp.groups.map((group) => [group.id, group.sortOrder]),
[["group-410", 0], ["group-480", 1], ["group-440", 2]]
);
assert.equal(moveEstimateGroup(movedUp, "group-440", 1), movedUp);
const removed = removeEstimateGroup(movedUp, "group-410");
assert.deepEqual(
removed.groups.map((group) => [group.id, group.sortOrder]),
[["group-480", 0], ["group-440", 1]]
);
assert.equal(removeEstimateGroup(removed, "missing"), removed);
});
it("orders loaded documents by sortOrder", () => {
const balance = sampleBalance();
const shuffled: EstimateBalanceSnapshot = {
...balance,
groups: [
{ ...balance.groups[1]!, sortOrder: 7 },
{
...balance.groups[0]!,
sortOrder: 2,
rows: [
{ ...balance.groups[0]!.rows[1]!, sortOrder: 5 },
{ ...balance.groups[0]!.rows[0]!, sortOrder: 1 },
],
},
],
};
const normalized = normalizeEstimateSortOrder(shuffled);
assert.deepEqual(
normalized.groups.map((group) => [group.id, group.sortOrder]),
[["group-410", 0], ["group-440", 1]]
);
assert.deepEqual(
normalized.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-a", 0], ["row-b", 1]]
);
});
});
describe("estimate balance saving", () => {
it("builds the trimmed server document with null for empty optional texts", () => {
let balance = sampleBalance();
balance = { ...balance, name: " Entwurf ", workStage: " ", dateIso: "" };
balance = updateEstimateGroup(balance, "group-440", { title: " Starkstrom ", editor: " " });
balance = updateEstimateRow(balance, "group-410", "row-a", {
name: " Hebeanlage ",
supply: " ELT ",
remark: " ",
});
const saveable = toSaveableEstimateBalance(balance);
assert.equal(saveable.name, "Entwurf");
assert.equal(saveable.workStage, null);
assert.equal(saveable.dateIso, null);
assert.equal(saveable.groups[1]!.title, "Starkstrom");
assert.equal(saveable.groups[1]!.editor, null);
assert.deepEqual(
{
name: saveable.groups[0]!.rows[0]!.name,
supply: saveable.groups[0]!.rows[0]!.supply,
remark: saveable.groups[0]!.rows[0]!.remark,
},
{ name: "Hebeanlage", supply: "ELT", remark: null }
);
});
it("copies only snapshot fields", () => {
const balance = sampleBalance() as EstimateBalanceSnapshot & { uiState?: string };
balance.uiState = "leak";
assert.equal("uiState" in toSaveableEstimateBalance(balance), false);
});
it("treats whitespace-only changes as unchanged", () => {
const balance = sampleBalance();
const withSpace = updateEstimateRow(balance, "group-410", "row-a", {
name: "Hebeanlage ",
});
assert.equal(estimateBalanceFingerprint(withSpace), estimateBalanceFingerprint(balance));
const changed = updateEstimateRow(balance, "group-410", "row-a", { quantity: 2 });
assert.notEqual(estimateBalanceFingerprint(changed), estimateBalanceFingerprint(balance));
});
it("reports documents the server would reject", () => {
assert.deepEqual(validateEstimateBalance(sampleBalance()), []);
const invalid = updateEstimateGroup(
{ ...sampleBalance(), name: " " },
"group-440",
{ title: " " }
);
assert.equal(validateEstimateBalance(invalid).length, 2);
});
it("names copies", () => {
assert.equal(createCopyName("Entwurfsplanung "), "Entwurfsplanung (Kopie)");
assert.equal(createCopyName("x".repeat(200)).length, 200);
});
});
describe("estimate balance frontend API", () => {
it("calls the estimate balance endpoints", async () => {
const requests: Array<{ url: string; method: string; body: unknown; keepalive?: boolean }> =
[];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
...(init?.keepalive ? { keepalive: true } : {}),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
const balance = sampleBalance();
await listEstimateBalances("project-1");
await getEstimateBalance("project-1", "balance-1");
await createEstimateBalance(
"project-1",
{ name: "Vorplanung", workStage: null, dateIso: "2026-09-26", template: "kg400" },
4
);
await createEstimateBalance(
"project-1",
{ name: "Kopie", template: "copy", copyFromBalanceId: "balance-1" },
5
);
await updateEstimateBalance("project-1", balance, 6);
await updateEstimateBalance("project-1", balance, 7, { keepalive: true });
await deleteEstimateBalance("project-1", "balance-1", 8);
await getEstimateComparison("project-1", "balance-1");
const base = "/api/projects/project-1/estimate-balances";
assert.deepEqual(requests, [
{ url: base, method: "GET", body: undefined },
{ url: `${base}/balance-1`, method: "GET", body: undefined },
{
url: base,
method: "POST",
body: {
name: "Vorplanung",
workStage: null,
dateIso: "2026-09-26",
template: "kg400",
expectedRevision: 4,
},
},
{
url: base,
method: "POST",
body: {
name: "Kopie",
template: "copy",
copyFromBalanceId: "balance-1",
expectedRevision: 5,
},
},
{
url: `${base}/balance-1`,
method: "PUT",
body: { expectedRevision: 6, balance },
},
{
url: `${base}/balance-1`,
method: "PUT",
body: { expectedRevision: 7, balance },
keepalive: true,
},
{ url: `${base}/balance-1`, method: "DELETE", body: { expectedRevision: 8 } },
{ url: `${base}/balance-1/comparison`, method: "GET", body: undefined },
]);
} finally {
globalThis.fetch = originalFetch;
}
});
it("exposes status and code of failed requests", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
error: "Projektstand veraltet.",
code: "PROJECT_REVISION_CONFLICT",
}),
{ status: 409, headers: { "Content-Type": "application/json" } }
);
try {
await assert.rejects(
updateEstimateBalance("project-1", sampleBalance(), 1),
(error: unknown) => {
assert.ok(error instanceof ApiRequestError);
assert.equal(error.message, "Projektstand veraltet.");
assert.equal(error.status, 409);
assert.equal(error.code, "PROJECT_REVISION_CONFLICT");
assert.equal(isRevisionConflictError(error), true);
return true;
}
);
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(isRevisionConflictError(new ApiRequestError("x", 400)), false);
assert.equal(isRevisionConflictError(new Error("x")), false);
});
});

View file

@ -0,0 +1,458 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js";
import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { estimateRows } from "../src/db/schema/estimate-balances.js";
import { projects } from "../src/db/schema/projects.js";
import {
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceSnapshot,
createEstimateBalanceUpdateProjectCommand,
parseEstimateBalanceSnapshot,
type EstimateBalanceProjectCommand,
} from "../src/estimate-balance/domain/estimate-balance-command.model.js";
import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js";
import {
calculateEstimateBalance,
calculateEstimateRow,
compareEstimateWithDetailed,
estimateDefaultGroups,
matchEstimateCostGroup,
roundEstimateValue,
type EstimateBalanceSnapshot,
type EstimateRowSnapshot,
} from "../src/shared/estimate-balance/estimate-balance.js";
const round2 = (value: number) => roundEstimateValue(value, 2);
let nextId = 0;
const id = () => `id-${++nextId}`;
function row(
name: string,
quantity: number,
powerPerUnitW: number,
simultaneityFactor: number,
cosPhi: number,
extra: Partial<EstimateRowSnapshot> = {}
): EstimateRowSnapshot {
return {
id: id(),
sortOrder: 0,
supply: "ELT",
name,
quantity,
unit: "piece",
connection: null,
powerPerUnitW,
simultaneityFactor,
cosPhi,
safetySupply: false,
remark: null,
...extra,
};
}
/** All rows of the office example "Leistungsbilanz - KG400" (Entwurfsplanung). */
function templateExample(): EstimateBalanceSnapshot {
const rowsByGroup: Record<string, EstimateRowSnapshot[]> = {
"410": [
row("Durchlauferhitzer 11kW", 9, 11200, 0.3, 1),
row("Durchlauferhitzer 4,4kW", 4, 4400, 0.3, 1),
],
"420": [
row("Fernwärmestation", 1, 1000, 0.8, 1),
row("Pumpen", 4, 200, 0.8, 0.9),
row("Schaltschrank Heizung", 1, 2000, 0.8, 1),
],
"430": [
row("Lüftungsgerät", 1, 6000, 0.8, 0.9),
row("Schaltschrank Lüftung", 1, 2000, 0.8, 0.9),
],
"434": [
row("Kälteaggregat Dach", 1, 1000, 0.8, 1),
row("Serverraum Kühlung Aggregat Dach", 1, 2000, 0.8, 1),
row("Serverraum Innengerät", 1, 1000, 0.8, 1),
],
"440": [
row("Beleuchtungsanlage Klassenzimmer", 17, 500, 0.8, 0.95),
row("Beleuchtung Allgemein", 50, 45, 0.7, 0.95),
row("Außenbeleuchtung", 10, 100, 0.4, 0.95),
row("Fassadenbeleuchtung", 20, 60, 0.5, 0.95),
row("Beleuchtung MFR", 15, 50, 0.8, 0.95),
row("Steckdosen Klassenzimmer", 170, 300, 0.2, 0.95),
row("Steckdosen 230 V allg. Verbr.", 100, 500, 0.2, 0.95),
row("Veranstaltungstechnik", 1, 4000, 0.5, 0.95),
row("Küchentechnische Anlagen", 1, 10000, 0.4, 0.8),
row("Sicherheitsbeleuchtung Zentrale", 1, 2000, 0.8, 0.9),
row("Spezielle Experimentieranlagen", 1, 6000, 0.4, 0.9),
],
"450": [row("EDV Systeme", 5, 3000, 0.8, 0.95)],
"460": [row("Aufzug", 1, 16000, 0.7, 0.95)],
"470": [row("E-Mobility Vorbereitung", 9, 11000, 0.3, 1)],
"480": [],
"": [row("Allgemein", 1, 2000, 1, 0.95)],
};
return {
id: "balance-1",
projectId: "project-1",
name: "Entwurfsplanung",
workStage: "LPH 3",
dateIso: "2023-06-30",
reserveAvPercent: 10,
reserveSvPercent: 0,
sortOrder: 0,
groups: estimateDefaultGroups.map((group, index) => ({
id: `group-${group.costGroup || "allg"}`,
costGroup: group.costGroup,
title: group.title,
editor: null,
sortOrder: index,
rows: rowsByGroup[group.costGroup] ?? [],
})),
};
}
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Foreign project" },
])
.run();
return context;
}
describe("estimate balance calculation", () => {
it("reproduces the office template totals", () => {
const result = calculateEstimateBalance(templateExample(), 400);
const groupTotals = result.groups.map((group) => [
round2(group.av.kw),
round2(group.av.kva),
]);
assert.deepEqual(groupTotals, [
[35.52, 35.52],
[3.04, 3.11],
[6.4, 7.11],
[3.2, 3.2],
[40.18, 43.31],
[12, 12.63],
[11.2, 11.79],
[29.7, 29.7],
[0, 0],
[2, 2.11],
]);
assert.equal(round2(result.total.av.kw), 143.24);
assert.equal(round2(result.total.av.kva), 148.48);
assert.equal(round2(result.reserve.av.kw), 14.32);
assert.equal(round2(result.reserve.av.kva), 14.85);
assert.equal(round2(result.withReserve.av.kw), 157.56);
assert.equal(round2(result.withReserve.av.kva), 163.33);
assert.equal(round2(result.maxCurrentA.av), 235.75);
assert.equal(result.total.sv.kw, 0);
assert.equal(result.maxCurrentA.sv, 0);
});
it("counts safety-supply rows in AV and additionally in SV, also per m²", () => {
const area = calculateEstimateRow({
quantity: 250,
powerPerUnitW: 12,
simultaneityFactor: 0.8,
cosPhi: 0.95,
safetySupply: true,
});
assert.equal(round2(area.av.kw), 2.4);
assert.equal(round2(area.av.kva), 2.53);
assert.deepEqual(area.sv, area.av);
const balance = templateExample();
balance.reserveSvPercent = 20;
balance.groups[4]!.rows[9]!.safetySupply = true; // Sicherheitsbeleuchtung Zentrale
const result = calculateEstimateBalance(balance, 400);
assert.equal(round2(result.total.sv.kw), 1.6);
assert.equal(round2(result.withReserve.sv.kw), 1.92);
assert.equal(round2(result.total.av.kw), 143.24);
});
it("rounds displayed values like the spreadsheet", () => {
assert.equal(roundEstimateValue(40.175), 40.18);
assert.equal(roundEstimateValue(143.235), 143.24);
assert.equal(roundEstimateValue(-1.825), -1.83);
assert.equal(roundEstimateValue(0.711111), 0.71);
assert.equal(roundEstimateValue(235.749, 1), 235.7);
});
it("matches detailed cost groups to the most specific estimate group", () => {
const codes = ["410", "430", "434", "440", "480", ""];
assert.equal(matchEstimateCostGroup("444", codes), "440");
assert.equal(matchEstimateCostGroup("434", codes), "434");
assert.equal(matchEstimateCostGroup("431", codes), "430");
assert.equal(matchEstimateCostGroup("KG 481.1", codes), "480");
assert.equal(matchEstimateCostGroup("4", codes), null);
assert.equal(matchEstimateCostGroup("", codes), null);
assert.equal(matchEstimateCostGroup(null, codes), null);
assert.equal(matchEstimateCostGroup("450", codes), null);
});
it("compares the estimate with detailed device rows per cost group", () => {
const comparison = compareEstimateWithDetailed(templateExample(), [
{ costGroup: "444", kw: 30, cosPhi: 0.9 },
{ costGroup: "445", kw: 12, cosPhi: null },
{ costGroup: "412", kw: 40, cosPhi: 1 },
{ costGroup: null, kw: 5, cosPhi: 1 },
{ costGroup: "399", kw: 1, cosPhi: 1 },
]);
const kg440 = comparison.groups.find((group) => group.costGroup === "440")!;
assert.equal(round2(kg440.detailed.kw), 42);
assert.equal(round2(kg440.detailed.kva), 45.33);
assert.equal(kg440.detailedRowCount, 2);
assert.ok(Math.abs(kg440.deltaKw - 1.825) < 1e-9);
assert.equal(round2(kg440.deltaPercent!), 4.54);
const kg410 = comparison.groups.find((group) => group.costGroup === "410")!;
assert.equal(round2(kg410.deltaKw), 4.48);
const kg480 = comparison.groups.find((group) => group.costGroup === "480")!;
assert.equal(kg480.deltaPercent, null);
assert.deepEqual(comparison.unassigned, { kw: 6, kva: 6, rowCount: 2 });
assert.equal(round2(comparison.total.detailed.kw), 88);
assert.equal(round2(comparison.total.estimatedAv.kw), 143.24);
});
});
describe("estimate balance snapshots and commands", () => {
it("validates snapshots strictly", () => {
const valid = templateExample();
assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3);
const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [
(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0),
(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5),
(balance) => (balance.groups[0]!.rows[0]!.quantity = -1),
(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "),
(balance) => (balance.groups[0]!.rows[0]!.supply = ""),
(balance) => (balance.groups[0]!.title = ""),
(balance) => (balance.dateIso = "30.06.2023"),
(balance) => (balance.reserveAvPercent = 101),
(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id),
(balance) => ((balance.groups[0]!.rows[0] as unknown as Record<string, unknown>).extra = 1),
];
for (const mutate of invalid) {
const balance = structuredClone(valid);
mutate(balance);
assert.throws(() => parseEstimateBalanceSnapshot(balance));
}
assert.equal(
createEstimateBalanceRequestSchema.safeParse({
expectedRevision: 0,
name: "Entwurf",
template: "copy",
}).success,
false
);
});
it("creates the KG 400 template and copies a Stand with new ids", () => {
const created = createEstimateBalanceSnapshot({
projectId: "project-1",
name: " Vorplanung ",
workStage: "LPH 2",
dateIso: "2026-09-26",
sortOrder: 0,
template: "kg400",
});
assert.equal(created.name, "Vorplanung");
assert.deepEqual(
created.groups.map((group) => group.costGroup),
estimateDefaultGroups.map((group) => group.costGroup)
);
assert.equal(created.reserveAvPercent, 10);
const source = templateExample();
const copy = createEstimateBalanceSnapshot({
projectId: "project-1",
name: "Entwurfsplanung Kopie",
workStage: null,
dateIso: null,
sortOrder: 1,
template: { copyFrom: source },
});
assert.notEqual(copy.id, source.id);
assert.notEqual(copy.groups[0]!.id, source.groups[0]!.id);
assert.notEqual(copy.groups[0]!.rows[0]!.id, source.groups[0]!.rows[0]!.id);
assert.equal(
round2(calculateEstimateBalance(copy, 400).total.av.kw),
143.24
);
});
it("inserts, updates and deletes balances with persisted undo and redo", () => {
const context = createTestDatabase();
try {
const store = new EstimateBalanceProjectCommandRepository(context.db);
const reader = new EstimateBalanceRepository(context.db);
const history = new ProjectHistoryRepository(context.db);
let revision = 0;
const run = (
command: EstimateBalanceProjectCommand,
source: "user" | "undo" | "redo" = "user"
) => {
const step = source === "user" ? null : history.getNextCommand("project-1", source);
const result = store.execute({
projectId: "project-1",
expectedRevision: revision,
source,
historyTargetChangeSetId: step?.changeSetId,
command,
});
revision += 1;
return result;
};
const balance = templateExample();
const insert = createEstimateBalanceInsertProjectCommand(balance);
const inserted = run(insert);
const stored = reader.findById("project-1", balance.id);
assert.deepEqual(stored, parseEstimateBalanceSnapshot(balance));
assert.equal(reader.listByProject("project-2").length, 0);
const target = structuredClone(stored!);
target.groups[0]!.rows.splice(1, 1);
target.groups[0]!.rows[0]!.quantity = 10;
target.groups[8]!.rows.push(row("GLT-Schrank", 2, 500, 1, 0.95));
target.groups[8]!.editor = "MSR";
target.reserveAvPercent = 15;
const update = createEstimateBalanceUpdateProjectCommand(stored!, target);
const updated = run(update);
assert.deepEqual(
reader.findById("project-1", balance.id),
parseEstimateBalanceSnapshot(target)
);
run(updated.inverse, "undo");
assert.deepEqual(reader.findById("project-1", balance.id), stored);
run(update, "redo");
assert.deepEqual(
reader.findById("project-1", balance.id),
parseEstimateBalanceSnapshot(target)
);
// A stale expected document is rejected.
assert.throws(
() => run(createEstimateBalanceUpdateProjectCommand(stored!, { ...stored!, name: "X" })),
/changed before update/
);
assert.throws(
() => createEstimateBalanceUpdateProjectCommand(stored!, structuredClone(stored!)),
/must change/
);
const current = reader.findById("project-1", balance.id)!;
const deleted = run(createEstimateBalanceDeleteProjectCommand(current));
assert.equal(reader.findById("project-1", balance.id), null);
assert.equal(context.db.select().from(estimateRows).all().length, 0);
run(deleted.inverse, "undo");
assert.deepEqual(reader.findById("project-1", balance.id), current);
// The original insert's inverse no longer matches the edited balance.
assert.throws(() => run(inserted.inverse), /changed before deletion/);
run(createEstimateBalanceDeleteProjectCommand(current));
assert.equal(reader.listByProject("project-1").length, 0);
assert.throws(
() => run(createEstimateBalanceInsertProjectCommand({ ...balance, projectId: "project-2" })),
/does not belong to project/
);
} finally {
context.close();
}
});
it("sums detailed device rows of all circuit lists of the project", () => {
const context = createTestDatabase();
try {
context.db
.insert(distributionBoards)
.values({ id: "board-1", projectId: "project-1", name: "UV 1" })
.run();
context.db
.insert(circuitLists)
.values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1" })
.run();
context.db
.insert(circuitSections)
.values({
id: "section-1",
circuitListId: "list-1",
key: "lighting-1",
displayName: "Beleuchtung 1",
prefix: "1F1",
sortOrder: 0,
})
.run();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1",
displayName: "Licht",
sortOrder: 0,
})
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
name: "Leuchte",
displayName: "Leuchte",
costGroup: "445",
quantity: 10,
powerPerUnit: 0.05,
simultaneityFactor: 0.8,
cosPhi: 0.95,
},
{
id: "row-2",
circuitId: "circuit-1",
name: "Steckdose",
displayName: "Steckdose",
costGroup: null,
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.5,
cosPhi: null,
},
])
.run();
const detailed = new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower(
"project-1"
);
assert.deepEqual(
detailed.map((entry) => ({ ...entry, kw: round2(entry.kw) })),
[
{ costGroup: "445", kw: 0.4, cosPhi: 0.95 },
{ costGroup: null, kw: 0.2, cosPhi: null },
]
);
assert.equal(
new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower("project-2").length,
0
);
} finally {
context.close();
}
});
});

View file

@ -0,0 +1,271 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCostGroupFilterOptions,
buildFloorFilterOptions,
buildSupplyTypeFilterOptions,
emptyDistributionBoardFilters,
emptyProjectDeviceFilters,
filterDistributionBoards,
filterProjectDevices,
isDistributionBoardFilterActive,
isProjectDeviceFilterActive,
retainAvailableFilterValues,
toggleFilterValue,
withoutCostGroupFilterId,
withoutFloorFilterId,
withoutSupplyTypeFilterId,
} from "../src/frontend/utils/overview-filters.js";
import type {
DistributionBoardDto,
FloorDto,
ProjectDeviceDto,
} from "../src/frontend/types.js";
type TestBoard = Pick<
DistributionBoardDto,
"id" | "name" | "floorId" | "supplyType"
>;
function board(
id: string,
name: string,
floorId: string | null,
supplyType: DistributionBoardDto["supplyType"]
): TestBoard {
return { id, name, floorId, supplyType };
}
function device(
id: string,
overrides: Partial<ProjectDeviceDto>
): ProjectDeviceDto {
return {
id,
projectId: "project-1",
name: `device-${id}`,
displayName: `Gerät ${id}`,
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "single_phase",
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
totalPower: 1,
cosPhi: null,
remark: null,
voltageV: null,
...overrides,
};
}
const floors: FloorDto[] = [
{ id: "floor-og", projectId: "project-1", name: "1. OG", sortOrder: 2 },
{ id: "floor-eg", projectId: "project-1", name: "EG", sortOrder: 1 },
];
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
const boards = [
board("b1", "UV-EG-01", "floor-eg", "AV"),
board("b2", "UV-OG-01", "floor-og", "SV"),
board("b3", "HV", null, "AV"),
board("b4", "SiBe-Zentrale", null, null),
];
describe("overview filters: distribution boards", () => {
it("returns every board without an active filter", () => {
assert.equal(isDistributionBoardFilterActive(emptyDistributionBoardFilters), false);
assert.deepEqual(
filterDistributionBoards(boards, emptyDistributionBoardFilters, floorNameById),
boards
);
});
it("also searches board number and location when the boards carry them", () => {
const withInfo = [
{ ...boards[0]!, boardNumber: "UV 0.1", location: "Raum 003 Technik" },
{ ...boards[1]!, boardNumber: null, location: null },
];
assert.deepEqual(
filterDistributionBoards(
withInfo,
{ ...emptyDistributionBoardFilters, query: "uv 0.1" },
floorNameById
).map((entry) => entry.id),
[withInfo[0]!.id]
);
assert.deepEqual(
filterDistributionBoards(
withInfo,
{ ...emptyDistributionBoardFilters, query: "technik" },
floorNameById
).map((entry) => entry.id),
[withInfo[0]!.id]
);
});
it("searches board and floor names case-insensitively and ignores blank queries", () => {
const byFloorName = filterDistributionBoards(
boards,
{ ...emptyDistributionBoardFilters, query: " og " },
floorNameById
);
assert.deepEqual(byFloorName.map((entry) => entry.id), ["b2"]);
const byBoardName = filterDistributionBoards(
boards,
{ ...emptyDistributionBoardFilters, query: "sibe" },
floorNameById
);
assert.deepEqual(byBoardName.map((entry) => entry.id), ["b4"]);
assert.equal(
isDistributionBoardFilterActive({ ...emptyDistributionBoardFilters, query: " " }),
false
);
});
it("filters floors with the dedicated option for boards without floor", () => {
const result = filterDistributionBoards(
boards,
{ ...emptyDistributionBoardFilters, floorIds: ["floor-eg", withoutFloorFilterId] },
floorNameById
);
assert.deepEqual(result.map((entry) => entry.id), ["b1", "b3", "b4"]);
});
it("combines supply type with the other criteria", () => {
const filters = {
query: "",
floorIds: [withoutFloorFilterId],
supplyTypes: ["AV"],
};
assert.equal(isDistributionBoardFilterActive(filters), true);
assert.deepEqual(
filterDistributionBoards(boards, filters, floorNameById).map((entry) => entry.id),
["b3"]
);
assert.deepEqual(
filterDistributionBoards(
boards,
{ ...emptyDistributionBoardFilters, supplyTypes: [withoutSupplyTypeFilterId] },
floorNameById
).map((entry) => entry.id),
["b4"]
);
});
it("offers floors in configured order followed by 'Ohne Etage'", () => {
assert.deepEqual(buildFloorFilterOptions(floors), [
{ id: "floor-eg", label: "EG" },
{ id: "floor-og", label: "1. OG" },
{ id: withoutFloorFilterId, label: "Ohne Etage" },
]);
});
it("offers enabled and used supply types in catalog order", () => {
const options = buildSupplyTypeFilterOptions(
["AV", "SV", "EV", "USV"],
["USV", "AV"],
boards,
{ AV: "AV – Allgemein", SV: "SV – Sicherheit" }
);
assert.deepEqual(options, [
{ id: "AV", label: "AV – Allgemein" },
{ id: "SV", label: "SV – Sicherheit" },
{ id: "USV", label: "USV" },
{ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" },
]);
});
});
describe("overview filters: project devices", () => {
const devices = [
device("d1", {
displayName: "Steckdose Büro",
costGroup: "444",
connectionKind: "Steckdose",
}),
device("d2", {
displayName: "Durchlauferhitzer",
phaseType: "three_phase",
category: "three_phase",
costGroup: " 445 ",
}),
device("d3", {
displayName: "Downlight",
category: "lighting",
costGroup: " ",
}),
device("d4", { displayName: "Kaffeemaschine", costGroup: null }),
];
it("searches names, connection kind, cost group and the German category label", () => {
assert.deepEqual(
filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "STECK" }).map(
(entry) => entry.id
),
["d1"]
);
assert.deepEqual(
filterProjectDevices(
devices,
{ ...emptyProjectDeviceFilters, query: "beleucht" },
{ lighting: "Beleuchtung" }
).map((entry) => entry.id),
["d3"]
);
});
it("filters category, phase type and cost group together", () => {
const filters = {
query: "",
categories: ["single_phase", "three_phase"],
phaseTypes: ["three_phase"],
costGroups: ["445"],
};
assert.equal(isProjectDeviceFilterActive(filters), true);
assert.deepEqual(
filterProjectDevices(devices, filters).map((entry) => entry.id),
["d2"]
);
});
it("treats blank cost groups as 'Ohne Kostengruppe'", () => {
assert.deepEqual(
filterProjectDevices(devices, {
...emptyProjectDeviceFilters,
costGroups: [withoutCostGroupFilterId],
}).map((entry) => entry.id),
["d3", "d4"]
);
assert.deepEqual(buildCostGroupFilterOptions(devices), [
{ id: "444", label: "444" },
{ id: "445", label: "445" },
{ id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" },
]);
});
it("returns an empty list when the filters hide every device", () => {
assert.deepEqual(
filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "gibt es nicht" }),
[]
);
assert.equal(isProjectDeviceFilterActive(emptyProjectDeviceFilters), false);
});
});
describe("overview filters: selection helpers", () => {
it("toggles values without mutating the selection", () => {
const selected = ["a"];
assert.deepEqual(toggleFilterValue(selected, "b"), ["a", "b"]);
assert.deepEqual(toggleFilterValue(selected, "a"), []);
assert.deepEqual(selected, ["a"]);
});
it("drops selected values that are no longer offered", () => {
assert.deepEqual(
retainAvailableFilterValues(["floor-eg", "deleted-floor"], buildFloorFilterOptions(floors)),
["floor-eg"]
);
});
});

View file

@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
assertPrintRevisionConsistency,
buildPreliminaryBoardHierarchy,
formatCircuitCable,
formatProtectionDevice,
summarizePrintBoard,
} from "../src/frontend/print/print-document-model.js";
import type {
CircuitTreeResponseDto,
DistributionBoardDto,
} from "../src/frontend/types.js";
function board(
id: string,
info: Partial<DistributionBoardDto> = {}
): DistributionBoardDto {
return {
id,
projectId: "project-1",
name: id,
floorId: null,
supplyType: "AV",
simultaneityFactor: 1,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
...info,
};
}
function tree(
rows: Array<{
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
}>,
boardSimultaneityFactor = 1
): CircuitTreeResponseDto {
const deviceRows = rows.map((row, index) => ({
id: `row-${index}`,
sortOrder: index,
name: "Gerät",
displayName: "Gerät",
manualQuantity: row.quantity,
rowTotalPower: row.quantity * row.powerPerUnit * row.simultaneityFactor,
...row,
}));
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
return {
circuitListId: "list",
currentRevision: 1,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
distributionBoardSimultaneityFactor: boardSimultaneityFactor,
distributionBoardTotalPower: total,
distributionBoardTotalPowerWithSimultaneityFactor:
total * boardSimultaneityFactor,
headerComponents: [],
footerComponents: [],
sections: [
{
id: "section",
key: "single_phase",
displayName: "1-phasig 1",
prefix: "-2F1.",
sortOrder: 1,
sectionTotalPower: total,
components: [],
circuits: [
{
id: "circuit",
circuitListId: "list",
sectionId: "section",
equipmentIdentifier: "-2F1.1",
sortOrder: 1,
isReserve: false,
circuitTotalPower: total,
deviceRows,
},
],
},
],
};
}
function summary(
id: string,
info: Partial<DistributionBoardDto>,
demandKw: number
) {
return summarizePrintBoard({
board: board(id, info),
circuitListId: id,
floorName: null,
tree: tree([{ quantity: 1, powerPerUnit: demandKw, simultaneityFactor: 1 }]),
});
}
describe("print document model", () => {
it("rejects mixed project revisions instead of printing mislabeled data", () => {
const summaries = [summary("uv", {}, 1)];
assert.doesNotThrow(() => assertPrintRevisionConsistency(1, summaries, 1));
assert.throws(() => assertPrintRevisionConsistency(0, summaries, 0), /neu laden/);
assert.throws(() => assertPrintRevisionConsistency(1, summaries, 2), /neu laden/);
assert.throws(() => assertPrintRevisionConsistency(1, [], 2), /neu laden/);
});
it("prints both cable type and cross-section without hiding either value", () => {
const circuit = tree([]).sections[0].circuits[0];
assert.equal(formatCircuitCable({ ...circuit, cableType: "NYM-J", cableCrossSection: "3x2,5" }), "NYM-J · 3x2,5");
assert.equal(formatCircuitCable({ ...circuit, cableType: "NYM-J" }), "NYM-J");
assert.equal(formatCircuitCable({ ...circuit, cableCrossSection: "3x2,5" }), "3x2,5");
assert.equal(formatCircuitCable(circuit), null);
});
it("does not guess a parent when feed text matches more than one board", () => {
const summaries = [
summary("a", { boardNumber: "HV" }, 2),
summary("b", { name: "HV" }, 3),
summary("uv", { fedFrom: " hv " }, 1),
];
for (const ordered of [summaries, [...summaries].reverse()]) {
const entries = buildPreliminaryBoardHierarchy(ordered);
assert.ok(entries.every((entry) => entry.parentBoardId === null));
assert.equal(entries.reduce((sum, entry) => sum + entry.cumulatedDemandPowerKw, 0), 6);
}
});
it("derives installed, device and demand power and the design current", () => {
const result = summarizePrintBoard({
board: board("uv"),
circuitListId: "uv",
floorName: "EG",
tree: tree(
[
{ quantity: 4, powerPerUnit: 2, simultaneityFactor: 0.5, cosPhi: 0.8 },
{ quantity: 1, powerPerUnit: 6, simultaneityFactor: 1 },
],
0.5
),
});
assert.equal(result.circuitCount, 1);
assert.equal(result.installedPowerKw, 14);
assert.equal(result.devicePowerKw, 10);
assert.equal(result.demandPowerKw, 5);
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
});
it("nests boards by matching free-text feed and sums downstream demand", () => {
const entries = buildPreliminaryBoardHierarchy([
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4),
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2),
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
]);
assert.deepEqual(
entries.map((entry) => [
entry.summary.board.id,
entry.depth,
entry.parentBoardId,
entry.cumulatedDemandPowerKw,
]),
[
["hv", 0, null, 16],
["uv-a", 1, "hv", 6],
["uv-b", 2, "uv-a", 2],
["uv-c", 0, null, 1],
]
);
});
it("keeps boards with cyclic feed texts at the top level", () => {
const entries = buildPreliminaryBoardHierarchy([
summary("a", { boardNumber: "A", fedFrom: "B" }, 1),
summary("b", { boardNumber: "B", fedFrom: "A" }, 1),
]);
assert.equal(entries.length, 2);
assert.ok(entries.some((entry) => entry.parentBoardId === null));
assert.equal(
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
2
);
});
it("formats protection devices for the plan table", () => {
assert.equal(
formatProtectionDevice({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }),
"LS B 16 A"
);
assert.equal(
formatProtectionDevice({
type: "FI_LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
}),
"FI/LS B 16 A, 30 mA Typ A"
);
assert.equal(formatProtectionDevice(undefined), null);
});
});

View file

@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
it("upgrades a populated pre-print database through the current schema without changing existing records", async () => {
const source = path.resolve("src/db/migrations");
const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-print-migration-"));
const journal = JSON.parse(readFileSync(path.join(source, "meta/_journal.json"), "utf8"));
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 6);
mkdirSync(path.join(staging, "meta"));
const files = [path.join(staging, "meta/_journal.json")];
writeFileSync(files[0], JSON.stringify(journal));
for (const entry of journal.entries) {
const file = path.join(staging, `${entry.tag}.sql`);
copyFileSync(path.join(source, `${entry.tag}.sql`), file);
files.push(file);
}
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: staging });
context.sqlite.exec(`
INSERT INTO projects (id, name, current_revision) VALUES ('p', 'Existing project', 17);
INSERT INTO distribution_boards (id, project_id, name, simultaneity_factor)
VALUES ('b', 'p', 'Existing board', 0.7);
INSERT INTO floors (id, project_id, name, sort_order)
VALUES ('f', 'p', 'Existing floor', 3);
`);
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 backup = path.join(staging, "before-print.db");
files.push(backup);
await context.sqlite.backup(backup);
migrate(context.db, { migrationsFolder: source });
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>;
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 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(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);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_balances").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_groups").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_rows").get()!.count, 0);
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
migrate(context.db, { migrationsFolder: source });
assert.deepEqual(context.sqlite.prepare("SELECT * FROM projects").get(), project);
} finally {
context.close();
for (const file of files) unlinkSync(file);
rmdirSync(path.join(staging, "meta"));
rmdirSync(staging);
}
});

View file

@ -0,0 +1,305 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
import { buildings } from "../src/db/schema/buildings.js";
import { floors } from "../src/db/schema/floors.js";
import { projects } from "../src/db/schema/projects.js";
import {
assertProjectFloorInsertProjectCommand,
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingSnapshot,
createProjectBuildingUpdateProjectCommand,
createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot,
createProjectFloorUpdateProjectCommand,
projectFloorCommandSchemaVersion,
type ProjectLocationStructureProjectCommand,
} from "../src/domain/models/project-location-structure-project-command.model.js";
import {
createBuildingSchema,
createFloorSchema,
updateFloorSchema,
} from "../src/shared/validation/project-structure.schemas.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Foreign project" },
])
.run();
return context;
}
/** Executes a user command and returns a runner for its undo and redo. */
function createRunner(context: DatabaseContext) {
const repository = new ProjectLocationStructureProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
let revision = 0;
const run = (
command: ProjectLocationStructureProjectCommand,
source: "user" | "undo" | "redo" = "user"
) => {
const step =
source === "user" ? null : history.getNextCommand("project-1", source);
if (source !== "user") {
assert.ok(step);
}
const result = repository.execute({
projectId: "project-1",
expectedRevision: revision,
source,
historyTargetChangeSetId: step?.changeSetId,
command,
});
revision += 1;
return result;
};
return { repository, run };
}
function getBuilding(context: DatabaseContext, id: string) {
return context.db.select().from(buildings).where(eq(buildings.id, id)).get();
}
function getFloor(context: DatabaseContext, id: string) {
return context.db.select().from(floors).where(eq(floors.id, id)).get();
}
describe("project building structure", () => {
it("creates, renames and deletes buildings with persisted undo and redo", () => {
const context = createTestDatabase();
try {
const { run } = createRunner(context);
const building = createProjectBuildingSnapshot("project-1", " Haus A ", 0);
assert.equal(building.name, "Haus A");
const insert = createProjectBuildingInsertProjectCommand(building);
const inserted = run(insert);
assert.deepEqual(getBuilding(context, building.id), building);
const renamed = { ...building, name: "Haus B" };
const update = createProjectBuildingUpdateProjectCommand(building, renamed);
const updated = run(update);
assert.equal(getBuilding(context, building.id)?.name, "Haus B");
run(updated.inverse, "undo");
assert.deepEqual(getBuilding(context, building.id), building);
run(inserted.inverse, "undo");
assert.equal(getBuilding(context, building.id), undefined);
run(insert, "redo");
run(update, "redo");
assert.deepEqual(getBuilding(context, building.id), renamed);
const deleted = run(createProjectBuildingDeleteProjectCommand(renamed));
assert.equal(getBuilding(context, building.id), undefined);
run(deleted.inverse, "undo");
assert.deepEqual(getBuilding(context, building.id), renamed);
} finally {
context.close();
}
});
it("assigns floors to buildings and blocks deleting a building in use", () => {
const context = createTestDatabase();
try {
const { run } = createRunner(context);
const houseA = createProjectBuildingSnapshot("project-1", "Haus A", 0);
const houseB = createProjectBuildingSnapshot("project-1", "Haus B", 1);
run(createProjectBuildingInsertProjectCommand(houseA));
run(createProjectBuildingInsertProjectCommand(houseB));
const floor = createProjectFloorSnapshot("project-1", "EG", 0, houseA.id);
const floorInsert = createProjectFloorInsertProjectCommand(floor);
assert.equal(floorInsert.schemaVersion, projectFloorCommandSchemaVersion);
run(floorInsert);
assert.deepEqual(getFloor(context, floor.id), floor);
const moved = { ...floor, buildingId: houseB.id };
const moveCommand = createProjectFloorUpdateProjectCommand(floor, moved);
const movedResult = run(moveCommand);
assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id);
assert.throws(
() => run(createProjectBuildingDeleteProjectCommand(houseB)),
/solange Etagen zugeordnet/
);
assert.ok(getBuilding(context, houseB.id));
run(movedResult.inverse, "undo");
assert.equal(getFloor(context, floor.id)?.buildingId, houseA.id);
run(moveCommand, "redo");
assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id);
const unassigned = { ...moved, buildingId: null };
run(createProjectFloorUpdateProjectCommand(moved, unassigned));
assert.equal(getFloor(context, floor.id)?.buildingId, null);
run(createProjectBuildingDeleteProjectCommand(houseB));
assert.equal(getBuilding(context, houseB.id), undefined);
} finally {
context.close();
}
});
it("rejects floors that reference a missing or foreign building", () => {
const context = createTestDatabase();
try {
const { run } = createRunner(context);
context.db
.insert(buildings)
.values({ id: "foreign", projectId: "project-2", name: "Fremd", sortOrder: 0 })
.run();
assert.throws(
() =>
run(
createProjectFloorInsertProjectCommand(
createProjectFloorSnapshot("project-1", "EG", 0, "foreign")
)
),
/foreign building/
);
assert.throws(
() =>
run(
createProjectFloorInsertProjectCommand(
createProjectFloorSnapshot("project-1", "EG", 0, "missing")
)
),
/foreign building/
);
assert.equal(context.db.select().from(floors).all().length, 0);
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
run(createProjectFloorInsertProjectCommand(floor));
assert.throws(
() =>
run(
createProjectFloorUpdateProjectCommand(floor, {
...floor,
buildingId: "foreign",
})
),
/foreign building/
);
assert.throws(
() => createProjectFloorUpdateProjectCommand(floor, { ...floor }),
/only the name or the building/
);
assert.throws(
() =>
createProjectFloorUpdateProjectCommand(floor, {
...floor,
sortOrder: 5,
name: "Erdgeschoss",
}),
/only the name or the building/
);
} finally {
context.close();
}
});
it("executes version 1 floor commands recorded before buildings existed", () => {
const context = createTestDatabase();
try {
const { run } = createRunner(context);
const legacyFloor = {
id: "floor-legacy",
projectId: "project-1",
name: "EG",
sortOrder: 0,
};
const legacyInsert = {
schemaVersion: 1 as const,
type: "project-floor.insert" as const,
payload: { floor: legacyFloor },
};
assertProjectFloorInsertProjectCommand(legacyInsert);
const inserted = run(legacyInsert);
assert.deepEqual(getFloor(context, legacyFloor.id), {
...legacyFloor,
buildingId: null,
});
// The inverse is always written in the current version.
assert.equal(inserted.inverse.schemaVersion, projectFloorCommandSchemaVersion);
const legacyUpdate = {
schemaVersion: 1 as const,
type: "project-floor.update" as const,
payload: {
expected: legacyFloor,
target: { ...legacyFloor, name: "Erdgeschoss" },
},
};
const updated = run(legacyUpdate);
assert.equal(getFloor(context, legacyFloor.id)?.name, "Erdgeschoss");
run(updated.inverse, "undo");
assert.equal(getFloor(context, legacyFloor.id)?.name, "EG");
// A version 1 snapshot must not carry buildingId, a version 2 one must.
assert.throws(() =>
assertProjectFloorInsertProjectCommand({
schemaVersion: 1,
type: "project-floor.insert",
payload: { floor: { ...legacyFloor, buildingId: null } },
})
);
assert.throws(() =>
assertProjectFloorInsertProjectCommand({
schemaVersion: 2,
type: "project-floor.insert",
payload: { floor: legacyFloor },
})
);
} finally {
context.close();
}
});
it("validates building and floor requests", () => {
assert.equal(
createBuildingSchema.safeParse({ expectedRevision: 0, name: "Haus A" }).success,
true
);
assert.equal(
createBuildingSchema.safeParse({ expectedRevision: 0, name: " " }).success,
false
);
assert.equal(
createBuildingSchema.safeParse({
expectedRevision: 0,
name: "Haus A",
sortOrder: 1,
}).success,
false
);
for (const buildingId of [undefined, null, "building-1"]) {
assert.equal(
createFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId })
.success,
true
);
}
assert.equal(
updateFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId: "" })
.success,
false
);
});
});

View file

@ -41,6 +41,7 @@ import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { buildings } from "../src/db/schema/buildings.js";
import { floors } from "../src/db/schema/floors.js";
import { rooms } from "../src/db/schema/rooms.js";
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
@ -78,6 +79,8 @@ import {
createDistributionBoardComponentUpdateProjectCommand,
} from "../src/domain/models/distribution-board-component-structure-project-command.model.js";
import {
createProjectBuildingInsertProjectCommand,
createProjectBuildingSnapshot,
createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot,
createProjectRoomInsertProjectCommand,
@ -1074,6 +1077,10 @@ describe("project command service", () => {
"MSR",
"SiBe",
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
}),
});
assert.equal(updated.history.currentRevision, 1);
@ -1606,6 +1613,43 @@ describe("project command service", () => {
}
});
it("dispatches building commands and floor assignments with persisted inverses", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const building = createProjectBuildingSnapshot("project-1", "Haus A", 0);
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectBuildingInsertProjectCommand(building),
});
const floor = createProjectFloorSnapshot("project-1", "EG", 0, building.id);
const created = service.executeUser({
projectId: "project-1",
expectedRevision: 1,
command: createProjectFloorInsertProjectCommand(floor),
});
assert.equal(created.history.currentRevision, 2);
assert.equal(
context.db.select().from(floors).where(eq(floors.id, floor.id)).get()
?.buildingId,
building.id
);
createService(context).undo({ projectId: "project-1", expectedRevision: 2 });
createService(context).undo({ projectId: "project-1", expectedRevision: 3 });
assert.equal(context.db.select().from(buildings).all().length, 0);
createService(context).redo({ projectId: "project-1", expectedRevision: 4 });
createService(context).redo({ projectId: "project-1", expectedRevision: 5 });
assert.deepEqual(
context.db.select().from(floors).where(eq(floors.id, floor.id)).get(),
floor
);
} finally {
context.close();
}
});
it("dispatches floor and room setup with their persisted inverses", () => {
const context = createTestDatabase();
try {

View file

@ -52,6 +52,10 @@ function getSettings(context: DatabaseContext) {
threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
currentRevision: projects.currentRevision,
})
.from(projects)
@ -81,6 +85,10 @@ function settings(
"MSR",
"SiBe",
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
...overrides,
};
}
@ -217,6 +225,10 @@ describe("project settings project-command repository", () => {
"MSR",
"SiBe",
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
},
},
]);
@ -289,6 +301,10 @@ describe("project settings project-command repository", () => {
"MSR",
"SiBe",
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
});
const undoStep = history.getNextCommand("project-1", "undo");
@ -317,6 +333,10 @@ describe("project settings project-command repository", () => {
"MSR",
"SiBe",
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 2,
});
assert.deepEqual(getDerivedVoltages(context), {
@ -356,6 +376,10 @@ describe("project settings project-command repository", () => {
"MSR",
"SiBe",
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 3,
});
assert.deepEqual(getDerivedVoltages(context), {
@ -433,6 +457,117 @@ describe("project settings project-command repository", () => {
}
});
it("persists the print cover fields with persisted undo", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
const readCover = () =>
context.db
.select({
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
})
.from(projects)
.where(eq(projects.id, "project-1"))
.get();
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand(
settings({
siteAddress: "Musterstraße 1\n12345 Musterstadt",
workStage: "LPH 5",
projectLead: "P. Leitung",
projectEditor: "B. Arbeiter",
})
),
});
assert.deepEqual(readCover(), {
siteAddress: "Musterstraße 1\n12345 Musterstadt",
workStage: "LPH 5",
projectLead: "P. Leitung",
projectEditor: "B. Arbeiter",
});
assert.equal(forward.inverse.schemaVersion, 3);
const undoStep = new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: forward.inverse,
});
assert.deepEqual(readCover(), {
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
});
assert.throws(
() =>
createProjectSettingsUpdateProjectCommand(
settings({ workStage: " LPH 5" })
),
/invalid values/
);
} finally {
context.close();
}
});
it("keeps the cover fields when applying a version 2 command", () => {
const context = createTestDatabase();
try {
context.db
.update(projects)
.set({ workStage: "LPH 3", projectLead: "P. Leitung" })
.where(eq(projects.id, "project-1"))
.run();
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
const {
siteAddress: _siteAddress,
workStage: _workStage,
projectLead: _projectLead,
projectEditor: _projectEditor,
...versionTwoPayload
} = settings({ name: "Stand aus Version 2" });
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: {
schemaVersion: 2,
type: "project.update-settings",
payload: versionTwoPayload,
},
});
const project = context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get();
assert.equal(project?.name, "Stand aus Version 2");
assert.equal(project?.workStage, "LPH 3");
assert.equal(project?.projectLead, "P. Leitung");
assert.equal(forward.inverse.schemaVersion, 2);
assert.equal(Object.keys(forward.inverse.payload).length, 9);
} finally {
context.close();
}
});
it("persists the project supply-type selection and rejects disabling an in-use type", () => {
const context = createTestDatabase();
try {

View file

@ -11,7 +11,13 @@ import {
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import {
deleteEstimateBalance,
insertEstimateBalance,
readEstimateBalances,
} from "../src/db/repositories/estimate-balance.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
@ -20,6 +26,7 @@ import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { buildings } from "../src/db/schema/buildings.js";
import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
@ -27,8 +34,48 @@ import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js";
import { parseEstimateBalanceSnapshot } from "../src/estimate-balance/domain/estimate-balance-command.model.js";
function estimateBalance(projectId: string, suffix = "") {
return parseEstimateBalanceSnapshot({
id: `estimate${suffix}`,
projectId,
name: `Vorplanung${suffix}`,
workStage: "LPH 2",
dateIso: "2026-09-27",
reserveAvPercent: 10,
reserveSvPercent: 5,
sortOrder: 0,
groups: [
{
id: `estimate-group${suffix}`,
costGroup: "440",
title: "Starkstromanlagen",
editor: "Planung",
sortOrder: 0,
rows: [
{
id: `estimate-row${suffix}`,
sortOrder: 0,
supply: "ELT",
name: "Beleuchtung",
quantity: 100,
unit: "area",
connection: "fixed",
powerPerUnitW: 12,
simultaneityFactor: 0.8,
cosPhi: 0.95,
safetySupply: false,
remark: null,
},
],
},
],
});
}
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
@ -59,6 +106,15 @@ function createTestDatabase(): DatabaseContext {
.where(eq(circuitSections.circuitListId, list.id))
.get();
assert.ok(section);
context.db
.insert(buildings)
.values({
id: "building-1",
projectId: "project-1",
name: "Haus A",
sortOrder: 0,
})
.run();
context.db
.insert(floors)
.values({
@ -66,6 +122,7 @@ function createTestDatabase(): DatabaseContext {
projectId: "project-1",
name: "EG",
sortOrder: 10,
buildingId: "building-1",
})
.run();
context.db
@ -248,7 +305,9 @@ describe("project snapshot repository", () => {
"device-1"
);
assert.equal(state.projectDevices.length, 1);
assert.equal(state.buildings.length, 1);
assert.equal(state.floors.length, 1);
assert.equal(state.floors[0].buildingId, "building-1");
assert.equal(state.rooms.length, 1);
assert.deepEqual(repository.listByProject("project-1"), [
created,
@ -377,15 +436,114 @@ describe("project snapshot repository", () => {
context.close();
}
});
it("restores snapshots stored with an older supported schema version", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
const snapshot = repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Vor dem Update",
});
assert.ok(snapshot);
// Rewrite the stored payload into the previous (version 5) shape, as it
// exists for snapshots created before the current version.
const payload = JSON.parse(
context.db
.select({ payloadJson: projectSnapshots.payloadJson })
.from(projectSnapshots)
.where(eq(projectSnapshots.id, snapshot.id))
.get()!.payloadJson
) as {
schemaVersion: number;
project: Record<string, unknown>;
distributionBoards: Array<Record<string, unknown>>;
buildings?: unknown;
floors: Array<Record<string, unknown>>;
};
payload.schemaVersion = 5;
delete payload.buildings;
for (const floor of payload.floors) {
delete floor.buildingId;
}
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
delete payload.project[field];
}
for (const board of payload.distributionBoards) {
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
delete board[field];
}
}
const payloadJson = JSON.stringify(payload);
context.db
.update(projectSnapshots)
.set({
schemaVersion: 5,
payloadJson,
payloadSha256: crypto
.createHash("sha256")
.update(payloadJson)
.digest("hex"),
})
.where(eq(projectSnapshots.id, snapshot.id))
.run();
const prepared = repository.prepareRestore("project-1", snapshot.id);
assert.ok(prepared);
const targetState = prepared.command.payload.targetState;
assert.equal(targetState.schemaVersion, projectStateSnapshotSchemaVersion);
assert.ok(targetState.distributionBoards.length > 0);
assert.ok(
targetState.distributionBoards.every(
(board) => board.boardNumber === null && board.fedFrom === null
)
);
// Version 5 predates buildings: the upgraded state has none.
assert.deepEqual(targetState.buildings, []);
assert.ok(targetState.floors.every((floor) => floor.buildingId === null));
} finally {
context.close();
}
});
});
describe("portable project transfer", () => {
it("keeps estimate balances outside normal snapshot restore", () => {
const context = createTestDatabase();
try {
insertEstimateBalance(context.db, estimateBalance("project-1"));
const current = readProjectStateSnapshot(context.db, "project-1");
assert.ok(current);
new ProjectStateRestoreCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "restore",
command: createProjectStateRestoreCommand(current.payloadSha256, {
...current.state,
project: { ...current.state.project, name: "Restored project" },
}),
});
assert.deepEqual(
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
["estimate"]
);
} finally {
context.close();
}
});
it("exports and atomically duplicates a complete project with new ids", () => {
const context = createTestDatabase();
try {
context.db.update(projects).set({ siteAddress: "Musterstraße 1", workStage: "LPH 5", projectLead: "Planung", projectEditor: "Test" }).where(eq(projects.id, "project-1")).run();
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "Technik", fedFrom: "HAK", remark: "Prüfstand" }).where(eq(distributionBoards.projectId, "project-1")).run();
insertEstimateBalance(context.db, estimateBalance("project-1"));
const repository = new ProjectTransferRepository(context.db);
const transfer = repository.exportProject("project-1");
assert.ok(transfer);
assert.equal(transfer.formatVersion, 2);
assert.equal(transfer.estimateBalances.length, 1);
const duplicate = repository.importDuplicate(transfer);
assert.notEqual(duplicate.projectId, "project-1");
assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)");
@ -398,6 +556,13 @@ describe("portable project transfer", () => {
assert.ok(source);
assert.ok(copied);
assert.equal(copied.currentRevision, 0);
assert.equal(copied.state.project.siteAddress, source.state.project.siteAddress);
assert.equal(copied.state.project.workStage, "LPH 5");
assert.equal(copied.state.project.projectLead, "Planung");
assert.equal(copied.state.project.projectEditor, "Test");
for (const field of ["boardNumber", "location", "fedFrom", "remark"] as const) {
assert.equal(copied.state.distributionBoards[0][field], source.state.distributionBoards[0][field]);
}
assert.equal(copied.state.circuits.length, source.state.circuits.length);
assert.notEqual(
copied.state.circuits[0].id,
@ -436,6 +601,11 @@ describe("portable project transfer", () => {
copied.state.distributionBoards[0].floorId,
copied.state.floors[0].id
);
assert.notEqual(copied.state.buildings[0].id, "building-1");
assert.equal(
copied.state.floors[0].buildingId,
copied.state.buildings[0].id
);
assert.equal(
copied.state.distributionBoards[0].supplyType,
"SV"
@ -444,6 +614,18 @@ describe("portable project transfer", () => {
copied.state.distributionBoards[0].simultaneityFactor,
0.65
);
const copiedBalances = readEstimateBalances(context.db, duplicate.projectId);
assert.equal(copiedBalances.length, 1);
assert.notEqual(copiedBalances[0].id, transfer.estimateBalances[0].id);
assert.notEqual(
copiedBalances[0].groups[0].id,
transfer.estimateBalances[0].groups[0].id
);
assert.notEqual(
copiedBalances[0].groups[0].rows[0].id,
transfer.estimateBalances[0].groups[0].rows[0].id
);
assert.equal(copiedBalances[0].groups[0].rows[0].name, "Beleuchtung");
} finally {
context.close();
}
@ -469,6 +651,14 @@ describe("portable project transfer", () => {
}),
/checksum verification failed/
);
assert.throws(
() =>
repository.importDuplicate({
...transfer,
estimateBalances: [estimateBalance("project-1", "-tampered")],
}),
/checksum verification failed/
);
assert.equal(
context.db.select().from(projects).all().length,
1
@ -478,15 +668,79 @@ describe("portable project transfer", () => {
}
});
it("imports legacy transfer version 1 with no estimate balances", () => {
const context = createTestDatabase();
try {
const snapshot = readProjectStateSnapshot(context.db, "project-1");
assert.ok(snapshot);
const legacyTransfer = {
format: "leistungsbilanz.project",
formatVersion: 1,
exportedAtIso: "2026-09-27T12:00:00.000Z",
payloadSha256: snapshot.payloadSha256,
projectState: snapshot.state,
};
const duplicate = new ProjectTransferRepository(context.db).importDuplicate(
legacyTransfer
);
assert.deepEqual(
readEstimateBalances(context.db, duplicate.projectId),
[]
);
} finally {
context.close();
}
});
it("replaces exported estimate balances atomically and restores them on undo", () => {
const context = createTestDatabase();
try {
insertEstimateBalance(context.db, estimateBalance("project-1"));
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
deleteEstimateBalance(context.db, "project-1", "estimate");
insertEstimateBalance(context.db, estimateBalance("project-1", "-changed"));
const prepared = transferRepository.prepareReplace("project-1", transfer);
assert.ok(prepared);
const restored = new ProjectStateRestoreCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "restore",
command: prepared.command,
});
assert.deepEqual(
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
["estimate"]
);
new ProjectStateRestoreCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: restored.revision.changeSetId,
command: restored.inverse,
});
assert.deepEqual(
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
["estimate-changed"]
);
} finally {
context.close();
}
});
it("rolls back a duplicate when a late project-state insert fails", () => {
const context = createTestDatabase();
try {
insertEstimateBalance(context.db, estimateBalance("project-1"));
const repository = new ProjectTransferRepository(context.db);
const transfer = repository.exportProject("project-1");
assert.ok(transfer);
context.sqlite.exec(`
CREATE TRIGGER fail_imported_device_row
BEFORE INSERT ON circuit_device_rows
CREATE TRIGGER fail_imported_estimate_row
BEFORE INSERT ON estimate_rows
BEGIN
SELECT RAISE(ABORT, 'forced project transfer failure');
END;

View file

@ -39,6 +39,7 @@ import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
@ -340,6 +341,8 @@ describe("project state restore command", () => {
it("restores a complete snapshot as a revision and supports undo and redo", () => {
const context = createTestDatabase();
try {
context.db.update(projects).set({ siteAddress: "Originaladresse", workStage: "LPH 3", projectLead: "Leitung", projectEditor: "Bearbeitung" }).where(eq(projects.id, "project-1")).run();
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "EG", fedFrom: "HAK", remark: "Original" }).where(eq(distributionBoards.projectId, "project-1")).run();
const snapshots = new ProjectSnapshotRepository(context.db);
const snapshot = snapshots.createNamed({
projectId: "project-1",
@ -351,6 +354,8 @@ describe("project state restore command", () => {
assert.ok(original);
changeCompleteProjectState(context);
context.db.update(projects).set({ siteAddress: "Neue Adresse", workStage: "LPH 5", projectLead: null, projectEditor: null }).where(eq(projects.id, "project-1")).run();
context.db.update(distributionBoards).set({ boardNumber: "UV-02", location: "OG", fedFrom: "HV", remark: null }).where(eq(distributionBoards.projectId, "project-1")).run();
const changed = readProjectStateSnapshot(context.db, "project-1");
assert.ok(changed);
assert.notEqual(changed.payloadSha256, original.payloadSha256);

View file

@ -8,6 +8,35 @@ import {
} from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
/** Removes the fields introduced by snapshot schema version 7. */
function withoutVersionSevenFields<
T extends { floors: object[] },
>(snapshot: T): T {
delete (snapshot as Record<string, unknown>).buildings;
for (const floor of snapshot.floors) {
delete (floor as Record<string, unknown>).buildingId;
}
return snapshot;
}
/** Removes the fields introduced by snapshot schema versions 6 and 7. */
function withoutVersionSixFields<
T extends { project: object; distributionBoards: object[]; floors: object[] },
>(snapshot: T): T {
withoutVersionSevenFields(snapshot);
const project = snapshot.project as Record<string, unknown>;
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
delete project[field];
}
for (const board of snapshot.distributionBoards) {
const record = board as Record<string, unknown>;
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
delete record[field];
}
}
return snapshot;
}
function minimalSnapshot() {
return {
schemaVersion: projectStateSnapshotSchemaVersion,
@ -29,6 +58,10 @@ function minimalSnapshot() {
"MSR",
"SiBe",
],
siteAddress: "Musterstraße 1\n12345 Musterstadt" as string | null,
workStage: "LPH 5" as string | null,
projectLead: "P. Leitung" as string | null,
projectEditor: null as string | null,
},
distributionBoards: [],
externalCsvConfiguration: null,
@ -45,7 +78,19 @@ function minimalSnapshot() {
distributionBoardComponentProtectionDevices: [],
circuits: [],
projectDevices: [],
floors: [],
buildings: [] as Array<{
id: string;
projectId: string;
name: string;
sortOrder: number;
}>,
floors: [] as Array<{
id: string;
projectId: string;
name: string;
sortOrder: number;
buildingId: string | null;
}>,
rooms: [],
};
}
@ -61,6 +106,10 @@ function protectedSnapshot() {
floorId: null,
supplyType: null,
simultaneityFactor: 1,
boardNumber: "UV 1.1" as string | null,
location: "Raum 003 Technik" as string | null,
fedFrom: "HV 0.1" as string | null,
remark: null as string | null,
},
],
circuitLists: [
@ -151,7 +200,7 @@ describe("project state snapshot model", () => {
});
it("upgrades the supported baseline snapshot with a safe building default", () => {
const baseline = structuredClone(minimalSnapshot());
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
@ -165,7 +214,7 @@ describe("project state snapshot model", () => {
});
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
const previous = structuredClone(minimalSnapshot());
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 2 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalCsvConfiguration;
delete (previous as Record<string, unknown>).externalModel;
@ -183,7 +232,7 @@ describe("project state snapshot model", () => {
});
it("upgrades snapshot version 3 with an empty external model", () => {
const previous = structuredClone(minimalSnapshot());
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 3 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalModel;
@ -199,8 +248,52 @@ describe("project state snapshot model", () => {
});
});
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 5 as typeof previous.schemaVersion;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(
{
siteAddress: upgraded.project.siteAddress,
workStage: upgraded.project.workStage,
projectLead: upgraded.project.projectLead,
projectEditor: upgraded.project.projectEditor,
},
{ siteAddress: null, workStage: null, projectLead: null, projectEditor: null }
);
assert.deepEqual(
{
boardNumber: upgraded.distributionBoards[0]?.boardNumber,
location: upgraded.distributionBoards[0]?.location,
fedFrom: upgraded.distributionBoards[0]?.fedFrom,
remark: upgraded.distributionBoards[0]?.remark,
},
{ boardNumber: null, location: null, fedFrom: null, remark: null }
);
});
it("keeps board info and cover values and rejects them in older versions", () => {
const parsed = parseProjectStateSnapshot(protectedSnapshot());
assert.equal(parsed.distributionBoards[0]?.boardNumber, "UV 1.1");
assert.equal(parsed.distributionBoards[0]?.location, "Raum 003 Technik");
assert.equal(parsed.distributionBoards[0]?.fedFrom, "HV 0.1");
assert.equal(parsed.project.siteAddress, "Musterstraße 1\n12345 Musterstadt");
assert.equal(parsed.project.workStage, "LPH 5");
const mislabeled = protectedSnapshot();
mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion;
assert.throws(() => parseProjectStateSnapshot(mislabeled));
const missingCover = structuredClone(protectedSnapshot());
delete (missingCover.project as Record<string, unknown>).workStage;
assert.throws(() => parseProjectStateSnapshot(missingCover));
});
it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
const previous = protectedSnapshot();
const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 4 as typeof previous.schemaVersion;
previous.circuits[0]!.isReserve = false;
previous.circuits[0]!.deviceRows = [{
@ -237,7 +330,10 @@ describe("project state snapshot model", () => {
delete (outdated.project as Record<string, unknown>).description;
assert.throws(() => parseProjectStateSnapshot(outdated));
assert.throws(() =>
parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: 7 })
parseProjectStateSnapshot({
...minimalSnapshot(),
schemaVersion: projectStateSnapshotSchemaVersion + 1,
})
);
});
@ -286,12 +382,99 @@ describe("project state snapshot model", () => {
projectId: "project-2",
name: "EG",
sortOrder: 10,
buildingId: null,
});
assert.throws(
() => parseProjectStateSnapshot(wrongOwner),
/floor belongs to a different project/
);
});
it("keeps buildings and validates floor building references", () => {
const snapshot = minimalSnapshot();
snapshot.buildings.push({
id: "building-1",
projectId: "project-1",
name: "Haus A",
sortOrder: 0,
});
snapshot.floors.push(
{
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: "building-1",
},
{
id: "floor-2",
projectId: "project-1",
name: "Keller",
sortOrder: 1,
buildingId: null,
}
);
const parsed = deserializeProjectStateSnapshot(
serializeProjectStateSnapshot(parseProjectStateSnapshot(snapshot))
);
assert.deepEqual(parsed.buildings, snapshot.buildings);
assert.deepEqual(
parsed.floors.map((floor) => floor.buildingId),
["building-1", null]
);
const unknownBuilding = structuredClone(snapshot);
unknownBuilding.floors[0]!.buildingId = "building-x";
assert.throws(
() => parseProjectStateSnapshot(unknownBuilding),
/floor building/
);
const foreignBuilding = structuredClone(snapshot);
foreignBuilding.buildings[0]!.projectId = "project-2";
assert.throws(
() => parseProjectStateSnapshot(foreignBuilding),
/building belongs to a different project/
);
const floorWithoutBuildingField = structuredClone(snapshot);
delete (floorWithoutBuildingField.floors[1] as Record<string, unknown>)
.buildingId;
assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField));
});
it("upgrades snapshot version 6 without buildings", () => {
const snapshot = minimalSnapshot();
snapshot.floors.push({
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: null,
});
const previous = withoutVersionSevenFields(structuredClone(snapshot));
previous.schemaVersion = 6 as typeof previous.schemaVersion;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(upgraded.buildings, []);
assert.deepEqual(upgraded.floors, [
{
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: null,
},
]);
// Version 6 cover values survive the upgrade.
assert.equal(upgraded.project.workStage, "LPH 5");
const mislabeled = structuredClone(snapshot);
mislabeled.schemaVersion = 6 as typeof mislabeled.schemaVersion;
assert.throws(() => parseProjectStateSnapshot(mislabeled));
});
});
describe("named project snapshot request", () => {