tsx runs tests without type checking and tsconfig.json covers only src, so 71 type errors had accumulated unnoticed. tsconfig.tests.json now checks every test and helper, and npm run typecheck includes it. Fixtures gain the fields the DTOs have grown since they were written (manualQuantity, circuit apparent power/cos phi, downstream board loads, section totals, building, cover and board info fields). Repository lookups that return "row ?? null" now declare the null in their type, which their callers already handle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
744 lines
24 KiB
TypeScript
744 lines
24 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/
|
|
);
|
|
});
|
|
|
|
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"])
|
|
);
|
|
});
|
|
});
|