Merge remote-tracking branch 'origin/main' into feature/cable-sizing
This commit is contained in:
commit
598003554a
136 changed files with 27182 additions and 1669 deletions
100
tests/building-structure-frontend-api.test.ts
Normal file
100
tests/building-structure-frontend-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
284
tests/building-structure-view.test.ts
Normal file
284
tests/building-structure-view.test.ts
Normal 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
45
tests/client-id.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
77
tests/document-branding.repository.test.ts
Normal file
77
tests/document-branding.repository.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
432
tests/estimate-balance-frontend.test.ts
Normal file
432
tests/estimate-balance-frontend.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
458
tests/estimate-balance.test.ts
Normal file
458
tests/estimate-balance.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
271
tests/overview-filters.test.ts
Normal file
271
tests/overview-filters.test.ts
Normal 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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
207
tests/print-document-model.test.ts
Normal file
207
tests/print-document-model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
65
tests/print-view-migration.test.ts
Normal file
65
tests/print-view-migration.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
305
tests/project-building-structure.test.ts
Normal file
305
tests/project-building-structure.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue