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 { 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).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: [], externalCsvConfiguration: 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, }, ], 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 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).externalCsvConfiguration; delete (baseline as Record).externalModel; const upgraded = parseProjectStateSnapshot(baseline); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.project.isPublicBuilding, false); assert.equal(upgraded.externalCsvConfiguration, 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.externalCsvConfiguration, 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.externalCsvConfiguration, null); assert.deepEqual(upgraded.externalModel, { source: null, importBatches: [], roomMappings: [], objects: [], }); }); 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 = 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), }) ); }); });