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 { 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"; /** Removes the fields introduced by snapshot schema version 9. */ function withoutVersionNineFields< T extends { distributionBoards: object[] }, >(snapshot: T): T { const record = snapshot as Record; record.externalCsvConfiguration = null; delete record.externalModelConfiguration; for (const board of snapshot.distributionBoards) { delete (board as Record).feedingDistributionBoardId; delete (board as Record).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).buildings; for (const floor of snapshot.floors) { delete (floor as Record).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; for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) { delete project[field]; } for (const board of snapshot.distributionBoards) { const record = board as Record; 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 const, 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).isPublicBuilding; delete (baseline as Record).externalModelConfiguration; delete (baseline as Record).externalCsvConfiguration; delete (baseline as Record).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).externalCsvConfiguration; delete (previous as Record).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).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.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.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)); const missingCover = structuredClone(protectedSnapshot()); delete (missingCover.project as Record).workStage; assert.throws(() => parseProjectStateSnapshot(missingCover)); }); 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]!.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).description; assert.throws(() => parseProjectStateSnapshot(outdated)); assert.throws(() => parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: projectStateSnapshotSchemaVersion + 1, }) ); }); 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/ ); }); 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) .buildingId; assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField)); }); it("keeps room areas and upgrades snapshot version 7 with an empty area", () => { const current = minimalSnapshot(); current.rooms.push({ id: "room-1", projectId: "project-1", floorId: null, roomNumber: "001", roomName: "Technik", areaM2: 23.456, }); assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456); const previous = 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)); }); }); 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, }) ); assert.throws(() => createNamedProjectSnapshotSchema.parse({ expectedRevision: 0, name: "x".repeat(201), }) ); }); });