leistungsbilanz-ts/tests/project-state-snapshot.test.ts
Julian Appel 38019fe087 Add explicit "no protection device" state for circuits
Circuits can now record that they deliberately have no protection device
(type NONE, shown as "–"), separate from circuits whose protection is
still open. Migration 0017 makes rated_current_a nullable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:11:28 +01:00

782 lines
25 KiB
TypeScript

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
deserializeProjectStateSnapshot,
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js";
import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
import type { CircuitGroupCategory } from "../src/shared/constants/circuit-group.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
import { zodIssues } from "./support/zod-assertions.js";
/** Removes the fields introduced by snapshot schema version 9. */
function withoutVersionNineFields<
T extends { distributionBoards: object[] },
>(snapshot: T): T {
const record = snapshot as Record<string, unknown>;
record.externalCsvConfiguration = null;
delete record.externalModelConfiguration;
for (const board of snapshot.distributionBoards) {
delete (board as Record<string, unknown>).feedingDistributionBoardId;
delete (board as Record<string, unknown>).feedingCircuitId;
}
return snapshot;
}
/** Removes the fields introduced by snapshot schema version 7. */
function withoutVersionSevenFields<
T extends { floors: object[]; distributionBoards: object[] },
>(snapshot: T): T {
withoutVersionNineFields(snapshot);
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,
project: {
id: "project-1",
name: "Projekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
isPublicBuilding: true,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"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: [],
externalModelConfiguration: null,
externalModel: {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
},
circuitLists: [],
circuitSections: [],
distributionBoardComponents: [],
circuitProtectionDevices: [],
distributionBoardComponentProtectionDevices: [],
circuits: [],
projectDevices: [],
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: [] as Array<{
id: string;
projectId: string;
floorId: string | null;
roomNumber: string;
roomName: string;
areaM2: number | null;
}>,
};
}
function protectedSnapshot() {
return {
...minimalSnapshot(),
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
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,
feedingDistributionBoardId: null as string | null,
feedingCircuitId: null as string | null,
},
],
circuitLists: [
{
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV 1 Stromkreisliste",
},
],
circuitSections: [
{
id: "section-1",
circuitListId: "list-1",
key: "lighting",
displayName: "Beleuchtung 1",
prefix: "-1F1.",
sortOrder: 10,
category: "lighting" as CircuitGroupCategory,
groupNumber: 1,
},
],
circuits: [
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1.1",
displayName: "Reserve",
sortOrder: 10,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
},
],
distributionBoardComponents: [
{
id: "component-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
},
],
circuitProtectionDevices: [
{
circuitId: "circuit-1",
type: "LS" as const,
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B" as const,
rcdType: null,
ratedResidualCurrentMa: null,
},
],
distributionBoardComponentProtectionDevices: [
{
componentId: "component-1",
type: "FI" as const,
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A" as const,
ratedResidualCurrentMa: 30,
},
],
};
}
describe("project state snapshot model", () => {
it("round-trips the current logical project state", () => {
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
assert.deepEqual(
deserializeProjectStateSnapshot(serializeProjectStateSnapshot(snapshot)),
snapshot
);
});
it("upgrades snapshot version 8 with empty feeding references", () => {
const previous = withoutVersionNineFields(protectedSnapshot());
previous.schemaVersion = 8 as typeof previous.schemaVersion;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(
upgraded.distributionBoards[0]?.feedingDistributionBoardId,
null
);
assert.equal(upgraded.distributionBoards[0]?.feedingCircuitId, null);
});
it("validates logical feeding references and cycles", () => {
const linked = protectedSnapshot();
linked.distributionBoards.push({
id: "board-2",
projectId: "project-1",
name: "UV 2",
floorId: null,
supplyType: null,
simultaneityFactor: 1,
boardNumber: "UV 2.1",
location: null,
fedFrom: null,
remark: null,
feedingDistributionBoardId: "board-1",
feedingCircuitId: "circuit-1",
});
linked.circuitLists.push({
id: "list-2",
projectId: "project-1",
distributionBoardId: "board-2",
name: "UV 2 Stromkreisliste",
});
linked.circuitSections.push({
id: "section-2",
circuitListId: "list-2",
key: "three_phase",
displayName: "Dreiphasig 1",
prefix: "-3F1.",
sortOrder: 10,
category: "three_phase",
groupNumber: 1,
});
const parsed = parseProjectStateSnapshot(linked);
assert.equal(parsed.distributionBoards[1]?.feedingCircuitId, "circuit-1");
let nextId = 0;
const remapped = remapProjectState(
parsed,
"project-copy",
() => `copy-${++nextId}`
);
const remappedUpstream = remapped.distributionBoards.find(
(board) => board.name === "UV 1"
);
const remappedDownstream = remapped.distributionBoards.find(
(board) => board.name === "UV 2"
);
assert.ok(remappedUpstream);
assert.ok(remappedDownstream);
assert.equal(
remappedDownstream.feedingDistributionBoardId,
remappedUpstream.id
);
assert.notEqual(remappedDownstream.feedingCircuitId, "circuit-1");
assert.ok(
remapped.circuits.some(
(circuit) => circuit.id === remappedDownstream.feedingCircuitId
)
);
const missingBoard = structuredClone(linked);
missingBoard.distributionBoards[1]!.feedingDistributionBoardId = null;
assert.throws(
() => parseProjectStateSnapshot(missingBoard),
/requires a feeding distribution board/
);
const cycle = structuredClone(linked);
cycle.distributionBoards[0]!.feedingDistributionBoardId = "board-2";
cycle.distributionBoards[0]!.feedingCircuitId = null;
cycle.distributionBoards[1]!.feedingCircuitId = null;
assert.throws(
() => parseProjectStateSnapshot(cycle),
/contains a cycle/
);
});
it("upgrades the supported baseline snapshot with a safe building default", () => {
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>).externalModelConfiguration;
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
delete (baseline as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(baseline);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.project.isPublicBuilding, false);
assert.equal(upgraded.externalModelConfiguration, null);
});
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
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;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalModelConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
});
});
it("upgrades snapshot version 3 with an empty external model", () => {
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 3 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalModelConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
});
});
it("rejects legacy CSV configuration and imported state with a German migration error", () => {
const configured = withoutVersionNineFields(structuredClone(minimalSnapshot()));
configured.schemaVersion = 9 as typeof configured.schemaVersion;
(configured as Record<string, unknown>).externalCsvConfiguration = {
id: "legacy",
projectId: "project-1",
configurationVersion: 1,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
};
assert.throws(() => parseProjectStateSnapshot(configured), /Revit-CSV-Daten/);
const imported = withoutVersionNineFields(structuredClone(minimalSnapshot()));
imported.schemaVersion = 9 as typeof imported.schemaVersion;
(imported.externalModel as Record<string, unknown>).source = {
id: "legacy-source",
projectId: "project-1",
name: "Legacy",
sourceType: "revit_csv",
};
assert.throws(() => parseProjectStateSnapshot(imported), /Revit-CSV-Daten/);
});
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),
zodIssues(
["project", "unrecognized_keys", "workStage"],
["distributionBoards.0", "unrecognized_keys", "boardNumber"]
)
);
const missingCover = structuredClone(protectedSnapshot());
delete (missingCover.project as Record<string, unknown>).workStage;
assert.throws(
() => parseProjectStateSnapshot(missingCover),
zodIssues(["project.workStage", "invalid_type"])
);
});
it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 4 as typeof previous.schemaVersion;
previous.circuits[0]!.isReserve = false;
(previous.circuits[0] as Record<string, unknown>).deviceRows = [{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 3,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 1,
remark: null,
overriddenFields: null,
}];
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.circuits[0]?.deviceRows[0]?.manualQuantity, 3);
});
it("rejects malformed baseline and unknown snapshot shapes", () => {
const outdated = structuredClone(minimalSnapshot());
delete (outdated.project as Record<string, unknown>).description;
assert.throws(
() => parseProjectStateSnapshot(outdated),
zodIssues(["project.description", "invalid_type"])
);
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
schemaVersion: projectStateSnapshotSchemaVersion + 1,
}),
zodIssues(["schemaVersion", "invalid_value"])
);
});
it("rejects invalid structure references", () => {
const boardFloor = protectedSnapshot();
(boardFloor.distributionBoards[0] as { floorId: string | null }).floorId =
"missing";
assert.throws(
() => parseProjectStateSnapshot(boardFloor),
/distribution board floor reference is invalid/
);
const roomFloor = protectedSnapshot();
roomFloor.rooms.push({
id: "room-1",
projectId: "project-1",
floorId: "missing",
roomNumber: "001",
roomName: "Technik",
areaM2: null,
});
assert.throws(
() => parseProjectStateSnapshot(roomFloor),
/room floor reference is invalid/
);
const circuitSection = protectedSnapshot();
circuitSection.circuits[0]!.sectionId = "missing";
assert.throws(
() => parseProjectStateSnapshot(circuitSection),
/circuit section reference is invalid/
);
});
it("rejects circuits whose reserve state contradicts their device rows", () => {
const withoutRows = protectedSnapshot();
withoutRows.circuits[0]!.isReserve = false;
assert.throws(
() => parseProjectStateSnapshot(withoutRows),
/reserve state must match its device rows/
);
});
it("validates component, protection and equipment relations", () => {
const snapshot = parseProjectStateSnapshot(protectedSnapshot());
assert.equal(snapshot.circuitProtectionDevices[0]?.tripCharacteristic, "B");
assert.equal(
snapshot.distributionBoardComponentProtectionDevices[0]
?.ratedResidualCurrentMa,
30
);
const duplicateBmk = protectedSnapshot();
duplicateBmk.distributionBoardComponents[0]!.equipmentIdentifier =
" -1f1.1 ";
assert.throws(
() => parseProjectStateSnapshot(duplicateBmk),
/duplicate equipment identifiers/
);
const invalidProtection = protectedSnapshot();
invalidProtection.circuitProtectionDevices[0] = {
...invalidProtection.circuitProtectionDevices[0]!,
ratedCurrentA: 35,
};
assert.throws(
() => parseProjectStateSnapshot(invalidProtection),
/protection-device configuration is invalid/
);
const protectedState = protectedSnapshot();
const noProtectionRecord = {
circuitId: protectedState.circuitProtectionDevices[0]!.circuitId,
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
};
assert.equal(
parseProjectStateSnapshot({
...protectedState,
circuitProtectionDevices: [noProtectionRecord],
}).circuitProtectionDevices[0]?.type,
"NONE"
);
assert.throws(
() =>
parseProjectStateSnapshot({
...protectedState,
circuitProtectionDevices: [
{ ...noProtectionRecord, ratedCurrentA: 16 },
],
}),
/protection-device configuration is invalid/
);
const componentWithoutDevice = {
...protectedState,
distributionBoardComponentProtectionDevices: [
{
...protectedState.distributionBoardComponentProtectionDevices[0]!,
type: "NONE",
},
],
};
assert.throws(() => parseProjectStateSnapshot(componentWithoutDevice));
});
it("rejects duplicate ids and invalid ownership", () => {
const duplicate = protectedSnapshot();
duplicate.distributionBoards.push({
...duplicate.distributionBoards[0]!,
name: "UV 2",
});
assert.throws(
() => parseProjectStateSnapshot(duplicate),
/duplicate distribution board ids/
);
const wrongOwner = minimalSnapshot();
wrongOwner.floors.push({
id: "floor-1",
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),
zodIssues(["floors.1.buildingId", "invalid_type"])
);
});
it("keeps room areas and upgrades snapshot version 7 with an empty area", () => {
const current = minimalSnapshot();
current.rooms.push({
id: "room-1",
projectId: "project-1",
floorId: null,
roomNumber: "001",
roomName: "Technik",
areaM2: 23.456,
});
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
const previous = withoutVersionNineFields(structuredClone(current));
previous.schemaVersion = 7 as typeof previous.schemaVersion;
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.rooms[0]?.areaM2, null);
});
it("upgrades snapshot version 6 without buildings", () => {
const snapshot = minimalSnapshot();
snapshot.floors.push({
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),
zodIssues(
["", "unrecognized_keys", "buildings"],
["floors.0", "unrecognized_keys", "buildingId"]
)
);
});
});
describe("named project snapshot request", () => {
it("normalizes bounded snapshot metadata", () => {
assert.deepEqual(
createNamedProjectSnapshotSchema.parse({
expectedRevision: 12,
name: " Freigabe ",
description: " Stand vor Änderung ",
}),
{
expectedRevision: 12,
name: "Freigabe",
description: "Stand vor Änderung",
}
);
});
it("rejects stale-shaped and excessive input", () => {
assert.throws(
() =>
createNamedProjectSnapshotSchema.parse({
expectedRevision: -1,
name: "",
unexpected: true,
}),
zodIssues(
["expectedRevision", "too_small"],
["name", "too_small"],
["", "unrecognized_keys", "unexpected"]
)
);
assert.throws(
() =>
createNamedProjectSnapshotSchema.parse({
expectedRevision: 0,
name: "x".repeat(201),
}),
zodIssues(["name", "too_big"])
);
});
});