Create clean release database baseline

This commit is contained in:
2026-07-31 14:07:26 +02:00
parent 5bee3cb103
commit 734a2bcfc4
129 changed files with 2121 additions and 30607 deletions
@@ -180,9 +180,6 @@ function createMoveTargetSnapshot(
equipmentIdentifier: "-1F4",
displayName: "Neuer Stromkreis",
sortOrder: 40,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
@@ -133,7 +133,6 @@ function createTestDatabase(): DatabaseContext {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
legacyConsumerId: "legacy-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte lokal",
@@ -163,7 +162,6 @@ function createInsertSnapshot(
id: "row-new",
circuitId: "circuit-reserve",
linkedProjectDeviceId: "device-1",
legacyConsumerId: null,
sortOrder: 10,
name: "Steckdose",
displayName: "Steckdose lokal",
@@ -423,10 +421,6 @@ describe("circuit device-row structure project-command repository", () => {
roomId: "room-foreign",
}),
createInsertSnapshot({ id: "row-1" }),
createInsertSnapshot({
id: "row-legacy",
legacyConsumerId: "legacy-user-value",
}),
]) {
assert.throws(() =>
store.execute({
+6 -43
View File
@@ -6,14 +6,12 @@ import {
buildDeviceRowEditPatch,
formatValue,
getCircuitSectionLabel,
getLegacyProjectColumnLayoutStorageKey,
getProjectColumnLayoutStorageKey,
isGridEditorControlTarget,
getBlockSortValue,
getCellKind,
getCircuitValue,
getDeviceValue,
migrateLegacyColumnLayout,
parseStoredColumnLayout,
parseNumeric,
} from "../src/frontend/utils/circuit-grid-model.js";
@@ -41,9 +39,11 @@ const circuit: CircuitTreeCircuitDto = {
equipmentIdentifier: "-1F1",
displayName: "Lighting circuit",
sortOrder: 10,
protectionType: "MCB",
protectionRatedCurrent: 16,
protectionCharacteristic: "B",
protectionDevice: {
type: "LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
},
cableType: "NYM-J",
cableCrossSection: "1.5 mm²",
cableLength: 20,
@@ -83,10 +83,6 @@ describe("circuit grid model", () => {
getProjectColumnLayoutStorageKey("project-1"),
"circuitTreeEditor.columnLayout.v3.project-1"
);
assert.equal(
getLegacyProjectColumnLayoutStorageKey("project-1"),
"circuitTreeEditor.columnLayout.v2.project-1"
);
const layout = parseStoredColumnLayout(
JSON.stringify({
order: ["displayName", "equipmentIdentifier"],
@@ -106,39 +102,6 @@ describe("circuit grid model", () => {
assert.equal(parseStoredColumnLayout("{invalid"), null);
});
it("adds the circuit protection column to legacy project layouts", () => {
const legacyLayout = parseStoredColumnLayout(
JSON.stringify({
order: [
"equipmentIdentifier",
"displayName",
"rowTotalPower",
"remark",
],
visible: [
"equipmentIdentifier",
"displayName",
"rowTotalPower",
"remark",
],
})
);
assert.ok(legacyLayout);
const migrated = migrateLegacyColumnLayout(legacyLayout);
assert.equal(
migrated.order.indexOf("protectionSummary"),
migrated.order.indexOf("rowTotalPower") + 1
);
assert.deepEqual(migrated.visible, [
"equipmentIdentifier",
"displayName",
"rowTotalPower",
"remark",
"protectionSummary",
]);
});
it("shows stable circuit sections with German labels", () => {
assert.equal(
getCircuitSectionLabel({
@@ -181,7 +144,7 @@ describe("circuit grid model", () => {
it("projects circuit and device values without mixing ownership", () => {
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B");
assert.equal(getCircuitValue(circuit, "protectionSummary"), "LS · 16 A · B");
assert.equal(
getCircuitValue(
{
+1 -1
View File
@@ -424,7 +424,7 @@ describe("circuit group numbering", () => {
});
});
it("rejects legacy, malformed and unsupported identifiers", () => {
it("rejects outdated, malformed and unsupported identifiers", () => {
for (const identifier of [
"-1F1",
"1F1.1",
@@ -90,7 +90,6 @@ function createTestDatabase(): DatabaseContext {
id: "subtree-row",
circuitId: "subtree-circuit",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
@@ -71,9 +71,6 @@ function createSnapshot(): CircuitGroupSubtreeSnapshot {
equipmentIdentifier: "-1F1.1",
displayName: "Beleuchtung",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
@@ -89,7 +86,6 @@ function createSnapshot(): CircuitGroupSubtreeSnapshot {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte Flur",
-12
View File
@@ -10,7 +10,6 @@ import {
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
normalizeKnownElectricalPhaseType,
} from "../src/domain/services/project-voltage.service.js";
describe("circuit power calculation", () => {
@@ -74,16 +73,5 @@ describe("project voltage derivation", () => {
assert.equal(resolveCircuitPhaseType("unassigned", [null]), "single_phase");
});
it("normalizes known legacy phase labels without guessing unknown values", () => {
assert.equal(normalizeKnownElectricalPhaseType("1phasig"), "single_phase");
assert.equal(normalizeKnownElectricalPhaseType("1-phasig"), "single_phase");
assert.equal(normalizeKnownElectricalPhaseType("3phase"), "three_phase");
assert.equal(normalizeKnownElectricalPhaseType("3-phasig"), "three_phase");
assert.equal(normalizeKnownElectricalPhaseType(null), null);
assert.throws(
() => normalizeKnownElectricalPhaseType("zweiphasig"),
/Unknown electrical phase type/
);
});
});
@@ -70,7 +70,7 @@ function createTestFixture(): TestFixture {
equipmentIdentifier: "-1F1",
displayName: "Licht Bestand",
sortOrder: 10,
protectionRatedCurrent: 10,
cableLength: 10,
voltage: 230,
controlRequirement: "none",
isReserve: 0,
@@ -112,7 +112,7 @@ describe("circuit project-command repository", () => {
const store = new CircuitProjectCommandRepository(fixture.context.db);
const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
protectionRatedCurrent: 16,
cableLength: 16,
voltage: 400,
controlRequirement: "DALI",
isReserve: true,
@@ -128,7 +128,7 @@ describe("circuit project-command repository", () => {
const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, null);
assert.equal(circuit.protectionRatedCurrent, 16);
assert.equal(circuit.cableLength, 16);
assert.equal(circuit.voltage, 230);
assert.equal(circuit.controlRequirement, "DALI");
assert.equal(circuit.isReserve, 1);
@@ -137,7 +137,7 @@ describe("circuit project-command repository", () => {
circuitId: "circuit-1",
changes: [
{ field: "displayName", value: "Licht Bestand" },
{ field: "protectionRatedCurrent", value: 10 },
{ field: "cableLength", value: 10 },
{ field: "controlRequirement", value: "none" },
{ field: "isReserve", value: false },
],
@@ -152,7 +152,7 @@ describe("circuit project-command repository", () => {
deserializeProjectCommand(changeSet.forwardPayloadJson),
createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
protectionRatedCurrent: 16,
cableLength: 16,
controlRequirement: "DALI",
isReserve: true,
})
@@ -184,7 +184,7 @@ describe("circuit project-command repository", () => {
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Licht Neu",
protectionRatedCurrent: 16,
cableLength: 16,
}),
});
@@ -199,7 +199,7 @@ describe("circuit project-command repository", () => {
const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, "Licht Bestand");
assert.equal(circuit.protectionRatedCurrent, 10);
assert.equal(circuit.cableLength, 10);
assert.equal(undone.revision.revisionNumber, 2);
assert.deepEqual(
fixture.context.db
+1 -2
View File
@@ -12,7 +12,7 @@ import {
} from "../src/frontend/utils/api.js";
describe("circuit structure command adapters", () => {
it("builds a complete row snapshot without legacy identities", () => {
it("builds a complete row snapshot", () => {
const row = buildCircuitDeviceRowInsertSnapshot({
id: "row-1",
circuitId: "circuit-1",
@@ -31,7 +31,6 @@ describe("circuit structure command adapters", () => {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
legacyConsumerId: null,
sortOrder: 20,
name: "Steckdose",
displayName: "Steckdose Büro",
@@ -117,9 +117,6 @@ function createTestDatabase(): TestFixture {
equipmentIdentifier: "-1F1",
displayName: "Bestand",
sortOrder: 10,
protectionType: "LS",
protectionRatedCurrent: 16,
protectionCharacteristic: "B",
cableType: "NYM-J",
cableCrossSection: "3x1,5",
cableLength: 18.5,
@@ -139,7 +136,6 @@ function createTestDatabase(): TestFixture {
id: "row-existing-1",
circuitId: "circuit-existing",
linkedProjectDeviceId: "device-1",
legacyConsumerId: "legacy-1",
sortOrder: 10,
name: "Leuchte A",
displayName: "Leuchte A lokal",
@@ -198,7 +194,6 @@ function createRow(
id,
circuitId,
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder,
name: `Gerät ${id}`,
displayName: `Gerät ${id}`,
@@ -241,9 +236,6 @@ function createCircuitSnapshot(
equipmentIdentifier: "-1F2",
displayName: "Neuer Stromkreis",
sortOrder: 20,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
@@ -569,14 +561,6 @@ describe("circuit structure project-command repository", () => {
}),
],
}),
createCircuitSnapshot(fixture, {
id: "legacy-row",
deviceRows: [
createRow("legacy-row", "row-legacy", 10, {
legacyConsumerId: "legacy-user-value",
}),
],
}),
];
for (const snapshot of invalidSnapshots) {
assert.throws(() =>
@@ -1,466 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { describe, it } from "node:test";
import Database from "better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
const migrationPath = path.resolve(
"src",
"db",
"migrations",
"0024_damp_mister_sinister.sql"
);
function applyComponentSchemaMigration(sqlite: Database.Database): void {
const migrationSql = fs.readFileSync(migrationPath, "utf8");
for (const statement of migrationSql.split("--> statement-breakpoint")) {
if (statement.trim()) {
sqlite.exec(statement);
}
}
}
function createLegacySchema(sqlite: Database.Database): void {
sqlite.pragma("foreign_keys = ON");
sqlite.exec(`
CREATE TABLE circuit_lists (
id text PRIMARY KEY NOT NULL
);
CREATE TABLE circuit_sections (
id text PRIMARY KEY NOT NULL,
circuit_list_id text NOT NULL REFERENCES circuit_lists(id) ON DELETE CASCADE,
key text NOT NULL,
display_name text NOT NULL,
prefix text NOT NULL,
sort_order integer DEFAULT 0 NOT NULL
);
CREATE TABLE circuits (
id text PRIMARY KEY NOT NULL,
circuit_list_id text NOT NULL REFERENCES circuit_lists(id) ON DELETE CASCADE,
equipment_identifier text NOT NULL,
protection_type text,
protection_rated_current real,
protection_characteristic text
);
`);
}
describe("distribution board component schema migration", () => {
it("creates the additive schema on an empty database", () => {
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
const tables = (
context.sqlite
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table'
ORDER BY name`
)
.all() as Array<{ name: string }>
).map((table) => table.name);
assert.equal(tables.includes("distribution_board_components"), true);
assert.equal(tables.includes("circuit_protection_devices"), true);
assert.deepEqual(
tables.includes("distribution_board_component_protection_devices"),
true
);
assert.deepEqual(
tables.includes("circuit_list_equipment_identifiers"),
true
);
const sectionColumns = (
context.sqlite
.prepare("PRAGMA table_info(circuit_sections)")
.all() as Array<{ name: string }>
).map((column) => column.name);
assert.equal(sectionColumns.includes("category"), true);
assert.equal(sectionColumns.includes("group_number"), true);
} finally {
context.close();
}
});
it("backfills groups and the BMK registry without rewriting existing planning data", () => {
const sqlite = new Database(":memory:");
try {
createLegacySchema(sqlite);
sqlite.exec(`
INSERT INTO circuit_lists (id) VALUES ('list-1');
INSERT INTO circuit_sections (
id,
circuit_list_id,
key,
display_name,
prefix,
sort_order
) VALUES
('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F', 10),
('single', 'list-1', 'single_phase', '1-phasig', '-2F', 20),
('three', 'list-1', 'three_phase', '3-phasig', '-3F', 30),
('unassigned', 'list-1', 'unassigned', 'Nicht zugeordnet', '-9F', 40);
INSERT INTO circuits (
id,
circuit_list_id,
equipment_identifier,
protection_type,
protection_rated_current,
protection_characteristic
) VALUES
('circuit-1', 'list-1', '-1F1', 'legacy fuse', 13, 'legacy'),
('circuit-2', 'list-1', '-2F4', NULL, NULL, NULL);
`);
applyComponentSchemaMigration(sqlite);
assert.deepEqual(
sqlite
.prepare(
`SELECT
id,
category,
group_number AS groupNumber
FROM circuit_sections
ORDER BY sort_order`
)
.all(),
[
{ id: "lighting", category: "lighting", groupNumber: 1 },
{ id: "single", category: "single_phase", groupNumber: 1 },
{ id: "three", category: "three_phase", groupNumber: 1 },
{ id: "unassigned", category: null, groupNumber: null },
]
);
assert.deepEqual(
sqlite
.prepare(
`SELECT
id,
equipment_identifier AS equipmentIdentifier,
protection_type AS protectionType,
protection_rated_current AS protectionRatedCurrent,
protection_characteristic AS protectionCharacteristic
FROM circuits
ORDER BY id`
)
.all(),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F1",
protectionType: "legacy fuse",
protectionRatedCurrent: 13,
protectionCharacteristic: "legacy",
},
{
id: "circuit-2",
equipmentIdentifier: "-2F4",
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
},
]
);
assert.deepEqual(
sqlite
.prepare(
`SELECT
owner_type AS ownerType,
owner_id AS ownerId,
equipment_identifier AS equipmentIdentifier
FROM circuit_list_equipment_identifiers
ORDER BY owner_id`
)
.all(),
[
{
ownerType: "circuit",
ownerId: "circuit-1",
equipmentIdentifier: "-1F1",
},
{
ownerType: "circuit",
ownerId: "circuit-2",
equipmentIdentifier: "-2F4",
},
]
);
assert.deepEqual(
sqlite
.prepare("SELECT count(*) AS count FROM circuit_protection_devices")
.get(),
{ count: 0 }
);
} finally {
sqlite.close();
}
});
it("keeps the shared BMK registry synchronized and rejects normalized collisions", () => {
const sqlite = new Database(":memory:");
try {
createLegacySchema(sqlite);
sqlite.exec(`
INSERT INTO circuit_lists (id) VALUES ('list-1');
INSERT INTO circuit_sections (
id,
circuit_list_id,
key,
display_name,
prefix
) VALUES ('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F');
INSERT INTO circuits (
id,
circuit_list_id,
equipment_identifier
) VALUES ('circuit-1', 'list-1', '-1F1');
`);
applyComponentSchemaMigration(sqlite);
sqlite
.prepare(
`INSERT INTO distribution_board_components (
id,
circuit_list_id,
equipment_identifier,
name,
role,
placement,
sort_order
) VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
"main-switch",
"list-1",
"-Q0",
"Hauptschalter",
"main_switch",
"header",
10
);
assert.throws(() =>
sqlite
.prepare(
`INSERT INTO distribution_board_components (
id,
circuit_list_id,
equipment_identifier,
name,
role,
placement
) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
"duplicate",
"list-1",
" -q0 ",
"Duplikat",
"auxiliary",
"footer"
)
);
assert.throws(() =>
sqlite
.prepare(
`INSERT INTO distribution_board_components (
id,
circuit_list_id,
equipment_identifier,
name,
role,
placement
) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
"circuit-duplicate",
"list-1",
"-1f1",
"Duplikat",
"auxiliary",
"footer"
)
);
sqlite
.prepare(
`INSERT INTO circuits (
id,
circuit_list_id,
equipment_identifier
) VALUES (?, ?, ?)`
)
.run("circuit-2", "list-1", "-1F2");
sqlite
.prepare(
`UPDATE circuits
SET equipment_identifier = ?
WHERE id = ?`
)
.run("-1F3", "circuit-2");
assert.deepEqual(
sqlite
.prepare(
`SELECT equipment_identifier AS equipmentIdentifier
FROM circuit_list_equipment_identifiers
WHERE owner_type = 'circuit' AND owner_id = 'circuit-2'`
)
.get(),
{ equipmentIdentifier: "-1F3" }
);
assert.throws(() =>
sqlite
.prepare(
`UPDATE circuits
SET equipment_identifier = ?
WHERE id = ?`
)
.run("-q0", "circuit-2")
);
assert.deepEqual(
sqlite
.prepare(
`SELECT equipment_identifier AS equipmentIdentifier
FROM circuits
WHERE id = 'circuit-2'`
)
.get(),
{ equipmentIdentifier: "-1F3" }
);
sqlite
.prepare("DELETE FROM distribution_board_components WHERE id = ?")
.run("main-switch");
assert.deepEqual(
sqlite
.prepare(
`SELECT count(*) AS count
FROM circuit_list_equipment_identifiers
WHERE owner_type = 'distribution_board_component'`
)
.get(),
{ count: 0 }
);
} finally {
sqlite.close();
}
});
it("stores independent one-to-one circuit and component protection state", () => {
const sqlite = new Database(":memory:");
try {
createLegacySchema(sqlite);
sqlite.exec(`
INSERT INTO circuit_lists (id) VALUES ('list-1');
INSERT INTO circuit_sections (
id,
circuit_list_id,
key,
display_name,
prefix
) VALUES ('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F');
INSERT INTO circuits (
id,
circuit_list_id,
equipment_identifier
) VALUES ('circuit-1', 'list-1', '-1F1');
`);
applyComponentSchemaMigration(sqlite);
sqlite.exec(`
INSERT INTO circuit_protection_devices (
circuit_id,
type,
rated_current_a,
trip_characteristic
) VALUES ('circuit-1', 'LS', 10, 'B');
INSERT INTO distribution_board_components (
id,
circuit_list_id,
section_id,
equipment_identifier,
name,
role,
placement
) VALUES (
'group-rcd',
'list-1',
'lighting',
'-1Q1.0',
'Gruppen-FI',
'group_residual_current_protection',
'group'
);
INSERT INTO distribution_board_component_protection_devices (
component_id,
type,
rated_current_a,
rcd_type,
rated_residual_current_ma
) VALUES ('group-rcd', 'FI', 40, 'A', 30);
`);
assert.deepEqual(
sqlite
.prepare("SELECT * FROM circuit_protection_devices")
.get(),
{
circuit_id: "circuit-1",
type: "LS",
rated_current_a: 10,
fuse_utilization_category: null,
trip_characteristic: "B",
rcd_type: null,
rated_residual_current_ma: null,
}
);
assert.deepEqual(
sqlite
.prepare(
"SELECT * FROM distribution_board_component_protection_devices"
)
.get(),
{
component_id: "group-rcd",
type: "FI",
rated_current_a: 40,
fuse_utilization_category: null,
trip_characteristic: null,
rcd_type: "A",
rated_residual_current_ma: 30,
}
);
sqlite.prepare("DELETE FROM circuits WHERE id = ?").run("circuit-1");
sqlite
.prepare("DELETE FROM distribution_board_components WHERE id = ?")
.run("group-rcd");
assert.deepEqual(
sqlite
.prepare("SELECT count(*) AS count FROM circuit_protection_devices")
.get(),
{ count: 0 }
);
assert.deepEqual(
sqlite
.prepare(
`SELECT count(*) AS count
FROM distribution_board_component_protection_devices`
)
.get(),
{ count: 0 }
);
} finally {
sqlite.close();
}
});
});
@@ -21,11 +21,9 @@ import { ProjectRevisionConflictError } from "../src/domain/errors/project-revis
import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
type DistributionBoardStructureProjectCommand,
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
import {
createDistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateProjectCommand,
} from "../src/domain/models/distribution-board-project-command.model.js";
import {
copyDistributionBoard,
@@ -62,43 +60,6 @@ function getStructureCounts(context: DatabaseContext) {
};
}
function legacySections(circuitListId: string) {
return [
{
id: "legacy-lighting",
circuitListId,
key: "lighting",
displayName: "Lighting",
prefix: "-1F",
sortOrder: 10,
},
{
id: "legacy-single-phase",
circuitListId,
key: "single_phase",
displayName: "Single-phase circuits",
prefix: "-2F",
sortOrder: 20,
},
{
id: "legacy-three-phase",
circuitListId,
key: "three_phase",
displayName: "Three-phase circuits",
prefix: "-3F",
sortOrder: 30,
},
{
id: "legacy-unassigned",
circuitListId,
key: "unassigned",
displayName: "Unassigned",
prefix: "-UF",
sortOrder: 90,
},
];
}
describe("distribution-board structure project command", () => {
it("builds the fixed setup and sends the expected frontend revision", async () => {
const structure = createDistributionBoardStructureSnapshot(
@@ -308,104 +269,6 @@ describe("distribution-board structure project command", () => {
}
});
it("applies stored version-one setup commands with unassigned new fields", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const current = createDistributionBoardStructureSnapshot(
"project-1",
"UV Alt",
{ floorId: null, supplyType: null }
);
const {
floorId: _floorId,
supplyType: _supplyType,
...legacyBoard
} = current.distributionBoard;
const legacyCommand = {
schemaVersion: 1,
type: "distribution-board.insert",
payload: {
structure: {
distributionBoard: legacyBoard,
circuitList: current.circuitList,
sections: legacySections(current.circuitList.id),
},
},
} as unknown as DistributionBoardStructureProjectCommand;
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: legacyCommand,
});
const inserted = context.db
.select()
.from(distributionBoards)
.get();
assert.equal(inserted?.floorId, null);
assert.equal(inserted?.supplyType, null);
assert.equal(
context.db.select().from(circuitSections).all().length,
4
);
assert.equal(
context.db.select().from(distributionBoardComponents).all()
.length,
0
);
} finally {
context.close();
}
});
it("keeps stored version-two setup commands component-free", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const current = createDistributionBoardStructureSnapshot(
"project-1",
"UV Version 2"
);
const command = {
schemaVersion: 2,
type: "distribution-board.insert",
payload: {
structure: {
distributionBoard: current.distributionBoard,
circuitList: current.circuitList,
sections: legacySections(current.circuitList.id),
},
},
} as DistributionBoardStructureProjectCommand;
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(inserted.inverse.schemaVersion, 2);
assert.equal(
context.db.select().from(circuitSections).all().length,
4
);
assert.equal(
context.db.select().from(distributionBoardComponents).all()
.length,
0
);
} finally {
context.close();
}
});
it("updates floor, supply type and simultaneity factor with persisted undo", () => {
const context = createTestDatabase();
try {
@@ -524,52 +387,6 @@ describe("distribution-board structure project command", () => {
}
});
it("keeps stored version-one board updates executable", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV Alt",
{ supplyType: "AV" }
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardInsertProjectCommand(structure),
});
const legacyUpdate = {
schemaVersion: 1,
type: "distribution-board.update",
payload: {
distributionBoardId: structure.distributionBoard.id,
changes: [{ field: "supplyType", value: "SV" }],
},
} as DistributionBoardUpdateProjectCommand;
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: legacyUpdate,
});
const board = context.db
.select()
.from(distributionBoards)
.get();
assert.equal(board?.supplyType, "SV");
assert.equal(board?.simultaneityFactor, 1);
} 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;
-5
View File
@@ -82,9 +82,6 @@ function createSnapshot(): DistributionBoardSubtreeSnapshot {
equipmentIdentifier: "-1F1.1",
displayName: "Licht",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: "NYM-J",
cableCrossSection: "3x1,5 mm²",
cableLength: 10,
@@ -100,7 +97,6 @@ function createSnapshot(): DistributionBoardSubtreeSnapshot {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
legacyConsumerId: "legacy-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
@@ -176,7 +172,6 @@ describe("distribution-board subtree snapshot", () => {
"device-1"
);
assert.equal(clone.circuits[0].deviceRows[0].roomId, "room-1");
assert.equal(clone.circuits[0].deviceRows[0].legacyConsumerId, null);
assert.equal(clone.circuits[0].protectionDevice?.circuitId, "new-3");
assert.doesNotThrow(() => assertDistributionBoardSubtreeSnapshot(clone));
});
@@ -1,57 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
inferSectionKeyFromEquipmentIdentifier,
inferSectionKeyFromLegacyInput,
normalizeCircuitNumber,
} from "../src/domain/services/legacy-consumer-migration-planner.js";
describe("legacy consumer migration planner", () => {
it("normalizes valid circuit numbers and rejects invalid formats", () => {
assert.equal(normalizeCircuitNumber(" -2f12 "), "-2F12");
assert.equal(normalizeCircuitNumber("2F12"), null);
assert.equal(normalizeCircuitNumber(""), null);
assert.equal(normalizeCircuitNumber(null), null);
});
it("infers section from equipment identifier prefix", () => {
assert.equal(inferSectionKeyFromEquipmentIdentifier("-1F4"), "lighting");
assert.equal(inferSectionKeyFromEquipmentIdentifier("-2F8"), "single_phase");
assert.equal(inferSectionKeyFromEquipmentIdentifier("-3F1"), "three_phase");
assert.equal(inferSectionKeyFromEquipmentIdentifier("-9F1"), null);
});
it("infers section from category/phase when circuit number is missing", () => {
assert.equal(
inferSectionKeyFromLegacyInput({
id: "1",
circuitNumber: null,
category: "Beleuchtung",
phaseType: null,
phaseCount: null,
}),
"lighting"
);
assert.equal(
inferSectionKeyFromLegacyInput({
id: "2",
circuitNumber: null,
category: null,
phaseType: "three-phase",
phaseCount: null,
}),
"three_phase"
);
assert.equal(
inferSectionKeyFromLegacyInput({
id: "3",
circuitNumber: null,
category: null,
phaseType: null,
phaseCount: null,
}),
null
);
});
});
@@ -1,245 +0,0 @@
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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { consumers } from "../src/db/schema/consumers.js";
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
import { projects } from "../src/db/schema/projects.js";
function createTestDatabase() {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
const board = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const [section] = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.limit(1)
.all();
return { context, circuitListId: board.id, sectionId: section.id };
}
function migrationInput(circuitListId: string, sectionId: string) {
return {
circuitListId,
circuits: [
{
circuit: {
circuitListId,
sectionId,
equipmentIdentifier: "-1F1",
displayName: "Legacy lighting",
sortOrder: 10,
},
deviceRows: [
{
legacyConsumerId: "consumer-1",
sortOrder: 10,
name: "Light 1",
displayName: "Light 1",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
legacyConsumerId: "consumer-2",
sortOrder: 20,
name: "Light 2",
displayName: "Light 2",
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.8,
},
],
},
],
report: {
legacyConsumerCount: 2,
createdCircuitCount: 1,
createdDeviceRowCount: 2,
duplicateGroupedCount: 1,
generatedIdentifierCount: 0,
unassignedRowCount: 0,
warningsJson: "[]",
generatedIdentifiersJson: "[]",
duplicateGroupsJson:
"[{\"normalizedCircuitNumber\":\"-1F1\",\"count\":2}]",
},
};
}
function assertNoMigrationWrites(context: DatabaseContext) {
assert.equal(context.db.select().from(circuits).all().length, 0);
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
assert.equal(
context.db.select().from(legacyConsumerCircuitMigrations).all().length,
0
);
assert.equal(
context.db.select().from(legacyConsumerMigrationReports).all().length,
0
);
}
describe("legacy consumer migration repository", () => {
it("reports every consumer without a completed migration mapping", async () => {
const { context, circuitListId, sectionId } = createTestDatabase();
try {
context.db
.insert(consumers)
.values([
{
id: "consumer-1",
projectId: "project-1",
circuitListId,
name: "Light 1",
quantity: 1,
installedPowerPerUnitKw: 0.1,
demandFactor: 1,
},
{
id: "consumer-2",
projectId: "project-1",
circuitListId: null,
name: "Light 2",
quantity: 1,
installedPowerPerUnitKw: 0.2,
demandFactor: 1,
},
])
.run();
const repository = new LegacyConsumerMigrationRepository(context.db);
assert.deepEqual(
(await repository.listSourceConsumersByCircuitList(circuitListId)).map(
(consumer) => consumer.id
),
["consumer-1"]
);
assert.deepEqual(
(await repository.listUnmigratedConsumers()).sort((left, right) =>
left.id.localeCompare(right.id)
),
[
{
id: "consumer-1",
projectId: "project-1",
circuitListId,
},
{
id: "consumer-2",
projectId: "project-1",
circuitListId: null,
},
]
);
repository.persistCircuitListMigration(
migrationInput(circuitListId, sectionId)
);
assert.deepEqual(await repository.listUnmigratedConsumers(), []);
} finally {
context.close();
}
});
it("commits circuits, rows, mappings and report together", () => {
const { context, circuitListId, sectionId } = createTestDatabase();
try {
const repository = new LegacyConsumerMigrationRepository(context.db);
repository.persistCircuitListMigration(
migrationInput(circuitListId, sectionId)
);
const persistedCircuits = context.db.select().from(circuits).all();
const persistedRows = context.db
.select()
.from(circuitDeviceRows)
.all()
.sort((left, right) => left.sortOrder - right.sortOrder);
const persistedMappings = context.db
.select()
.from(legacyConsumerCircuitMigrations)
.all()
.sort((left, right) => left.consumerId.localeCompare(right.consumerId));
const persistedReports = context.db
.select()
.from(legacyConsumerMigrationReports)
.all();
assert.equal(persistedCircuits.length, 1);
assert.equal(persistedRows.length, 2);
assert.equal(persistedMappings.length, 2);
assert.equal(persistedReports.length, 1);
assert.deepEqual(
persistedMappings.map((mapping) => [
mapping.consumerId,
mapping.circuitId,
mapping.circuitDeviceRowId,
mapping.circuitListId,
]),
[
[
"consumer-1",
persistedCircuits[0].id,
persistedRows[0].id,
circuitListId,
],
[
"consumer-2",
persistedCircuits[0].id,
persistedRows[1].id,
circuitListId,
],
]
);
assert.equal(persistedReports[0].createdCircuitCount, 1);
assert.equal(persistedReports[0].createdDeviceRowCount, 2);
} finally {
context.close();
}
});
it("rolls back all migrated entities when the final report write fails", () => {
const { context, circuitListId, sectionId } = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_migration_report
BEFORE INSERT ON legacy_consumer_migration_reports
BEGIN
SELECT RAISE(ABORT, 'forced migration report failure');
END;
`);
const repository = new LegacyConsumerMigrationRepository(context.db);
assert.throws(
() =>
repository.persistCircuitListMigration(
migrationInput(circuitListId, sectionId)
),
/forced migration report failure/
);
assertNoMigrationWrites(context);
} finally {
context.close();
}
});
});
@@ -1,130 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type {
LegacyConsumerMigrationDependencies,
LegacyMigrationCircuitInput,
LegacyMigrationReportInput,
} from "../src/domain/ports/legacy-consumer-migration.store.js";
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
describe("legacy consumer migration service", () => {
it("plans the migration through injected readers and persists one complete batch", async () => {
let defaultsCreatedFor: string | null = null;
let persisted:
| {
circuitListId: string;
circuits: LegacyMigrationCircuitInput[];
report: LegacyMigrationReportInput;
}
| undefined;
const dependencies: LegacyConsumerMigrationDependencies = {
circuitListReader: {
async findById(projectId, circuitListId) {
assert.equal(projectId, "project-1");
assert.equal(circuitListId, "list-1");
return { id: circuitListId };
},
},
sectionStore: {
async createDefaults(circuitListId) {
defaultsCreatedFor = circuitListId;
},
async listByCircuitList() {
return [
{ id: "section-1", key: "single_phase", prefix: "-2F" },
{ id: "section-2", key: "unassigned", prefix: "-XF" },
];
},
},
circuitReader: {
async listByCircuitList() {
return [
{
sectionId: "section-1",
equipmentIdentifier: "-2F2",
sortOrder: 10,
},
];
},
},
roomReader: {
async listByProject() {
return [
{ id: "room-1", roomNumber: "1.01", roomName: "Besprechung" },
];
},
},
migrationStore: {
async listSourceConsumersByCircuitList() {
return [
{
id: "consumer-1",
projectDeviceId: null,
roomId: "room-1",
circuitNumber: null,
description: "Steckdosen Besprechung",
name: "Steckdose",
category: null,
deviceType: null,
phaseType: null,
tradeOrCostGroup: "KG 440",
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
comment: null,
quantity: 2,
installedPowerPerUnitKw: 0.2,
demandFactor: 0.8,
voltageV: 230,
phaseCount: 1,
powerFactor: 0.95,
note: "Bestand",
},
];
},
async listMigratedConsumerIds() {
return [];
},
persistCircuitListMigration(input) {
persisted = input;
},
},
};
const report = await new LegacyConsumerMigrationService(
dependencies
).migrateCircuitList("project-1", "list-1");
assert.equal(defaultsCreatedFor, "list-1");
assert.deepEqual(report.generatedIdentifiers, ["-2F3"]);
assert.equal(report.createdCircuitCount, 1);
assert.equal(report.createdDeviceRowCount, 1);
assert.equal(persisted?.circuitListId, "list-1");
assert.equal(persisted?.circuits[0].circuit.sectionId, "section-1");
assert.equal(
persisted?.circuits[0].circuit.equipmentIdentifier,
"-2F3"
);
assert.deepEqual(persisted?.circuits[0].deviceRows[0], {
linkedProjectDeviceId: undefined,
legacyConsumerId: "consumer-1",
sortOrder: 10,
name: "Steckdose",
displayName: "Steckdosen Besprechung",
phaseType: undefined,
connectionKind: undefined,
costGroup: "KG 440",
category: undefined,
roomId: "room-1",
roomNumberSnapshot: "1.01",
roomNameSnapshot: "Besprechung",
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "Bestand",
});
});
});
+1 -10
View File
@@ -60,7 +60,7 @@ describe("serialized project commands", () => {
circuitId: "circuit-1",
changes: {
displayName: "Werkstatt",
protectionRatedCurrent: 16,
cableLength: 16,
isReserve: false,
remark: null,
},
@@ -378,7 +378,6 @@ describe("project-device structure project commands", () => {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Luminaire",
displayName: "Local lighting",
@@ -445,7 +444,6 @@ describe("circuit device-row structure project commands", () => {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
@@ -517,9 +515,6 @@ describe("circuit device-row move project commands", () => {
equipmentIdentifier: "-1F3",
displayName: "Neuer Stromkreis",
sortOrder: 30,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
@@ -683,7 +678,6 @@ describe("circuit structure project commands", () => {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
@@ -709,9 +703,6 @@ describe("circuit structure project commands", () => {
equipmentIdentifier: "-1F1",
displayName: "Beleuchtung",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
-7
View File
@@ -555,7 +555,6 @@ describe("project command service", () => {
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 20,
name: "Steckdose",
displayName: "Steckdose",
@@ -623,9 +622,6 @@ describe("project command service", () => {
equipmentIdentifier: "-1F2",
displayName: "Reserve",
sortOrder: 20,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
@@ -745,9 +741,6 @@ describe("project command service", () => {
equipmentIdentifier: "-1F2",
displayName: "Neuer Stromkreis",
sortOrder: 20,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
@@ -1,98 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { describe, it } from "node:test";
import Database from "better-sqlite3";
describe("project device canonical-field migration", () => {
it("preserves canonical values while removing transitional mirror columns", () => {
const sqlite = new Database(":memory:");
try {
sqlite.exec(`
CREATE TABLE project_devices (
id text PRIMARY KEY NOT NULL,
power_per_unit real NOT NULL,
simultaneity_factor real NOT NULL,
phase_type text NOT NULL,
cos_phi real,
remark text,
voltage_v real,
installed_power_per_unit_kw real NOT NULL,
demand_factor real NOT NULL,
phase_count integer,
power_factor real,
note text
);
INSERT INTO project_devices VALUES (
'device-1',
0.25,
0.8,
'three_phase',
0.95,
'Canonical remark',
400,
0.25,
0.8,
3,
0.95,
'Canonical remark'
);
`);
const migrationSql = fs.readFileSync(
path.resolve(
"src",
"db",
"migrations",
"0011_project_device_canonical_fields.sql"
),
"utf8"
);
for (const statement of migrationSql.split("--> statement-breakpoint")) {
if (statement.trim()) {
sqlite.exec(statement);
}
}
const columns = (
sqlite.prepare("PRAGMA table_info(project_devices)").all() as Array<{
name: string;
}>
).map((column) => column.name);
assert.deepEqual(columns, [
"id",
"power_per_unit",
"simultaneity_factor",
"phase_type",
"cos_phi",
"remark",
"voltage_v",
]);
assert.deepEqual(
sqlite
.prepare(
`SELECT
power_per_unit AS powerPerUnit,
simultaneity_factor AS simultaneityFactor,
phase_type AS phaseType,
cos_phi AS cosPhi,
remark,
voltage_v AS voltageV
FROM project_devices
WHERE id = ?`
)
.get("device-1"),
{
powerPerUnit: 0.25,
simultaneityFactor: 0.8,
phaseType: "three_phase",
cosPhi: 0.95,
remark: "Canonical remark",
voltageV: 400,
}
);
} finally {
sqlite.close();
}
});
});
+2 -2
View File
@@ -30,8 +30,8 @@ describe("project device circuit-first schema", () => {
it("requires the circuit-first power and phase fields", () => {
const result = createProjectDeviceSchema.safeParse({
name: "Legacy device",
displayName: "Legacy device",
name: "Test device",
displayName: "Test device",
quantity: 1,
installedPowerPerUnitKw: 0.1,
demandFactor: 1,
@@ -27,7 +27,6 @@ function linkedRow() {
id: "row1",
circuitId: "c1",
linkedProjectDeviceId: "pd1",
legacyConsumerId: null,
sortOrder: 10,
name: "Old luminaire",
displayName: "Local display name",
@@ -9,7 +9,6 @@ import {
} 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 { consumers } from "../src/db/schema/consumers.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { floors } from "../src/db/schema/floors.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
@@ -423,53 +422,6 @@ describe("project-location structure project command", () => {
floorContext.close();
}
const roomContext = createTestDatabase();
try {
const repository =
new ProjectLocationStructureProjectCommandRepository(
roomContext.db
);
const room = createProjectRoomSnapshot("project-1", {
roomNumber: "001",
roomName: "Technik",
});
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectRoomInsertProjectCommand(room),
});
roomContext.db
.insert(consumers)
.values({
id: "legacy-consumer-1",
projectId: "project-1",
roomId: room.id,
name: "Bestand",
quantity: 1,
installedPowerPerUnitKw: 1,
demandFactor: 1,
})
.run();
const undoStep = new ProjectHistoryRepository(
roomContext.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse,
}),
/referenced project room/
);
} finally {
roomContext.close();
}
const rollbackContext = createTestDatabase();
try {
rollbackContext.sqlite.exec(`
@@ -365,37 +365,6 @@ describe("project settings project-command repository", () => {
}
});
it("applies persisted version-one voltage commands without clearing metadata", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: {
schemaVersion: 1,
type: "project.update-settings",
payload: {
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
},
},
});
assert.deepEqual(getSettings(context), {
...settings({
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
currentRevision: 1,
});
} finally {
context.close();
}
});
it("persists the project supply-type selection and rejects disabling an in-use type", () => {
const context = createTestDatabase();
try {
+8 -88
View File
@@ -172,18 +172,6 @@ function createTestDatabase(): DatabaseContext {
return context;
}
function removeVersionSevenFields(state: Record<string, unknown>) {
delete state.distributionBoardComponents;
delete state.circuitProtectionDevices;
delete state.distributionBoardComponentProtectionDevices;
for (const section of state.circuitSections as Array<
Record<string, unknown>
>) {
delete section.category;
delete section.groupNumber;
}
}
describe("project snapshot repository", () => {
it("captures, hashes and lists a complete named logical snapshot", () => {
const context = createTestDatabase();
@@ -354,41 +342,23 @@ describe("project snapshot repository", () => {
) as Record<string, unknown> & {
project: Record<string, unknown>;
};
currentPayload.schemaVersion = 1;
removeVersionSevenFields(currentPayload);
delete currentPayload.project.internalProjectNumber;
delete currentPayload.project.externalProjectNumber;
delete currentPayload.project.buildingOwner;
delete currentPayload.project.description;
delete currentPayload.project.enabledDistributionBoardSupplyTypes;
for (const board of currentPayload.distributionBoards as Array<
Record<string, unknown>
>) {
delete board.floorId;
delete board.supplyType;
delete board.simultaneityFactor;
}
const legacyPayloadJson = JSON.stringify(currentPayload);
currentPayload.schemaVersion = 99;
const unsupportedPayloadJson = JSON.stringify(currentPayload);
context.db
.update(projectSnapshots)
.set({
schemaVersion: 1,
payloadJson: legacyPayloadJson,
schemaVersion: 99,
payloadJson: unsupportedPayloadJson,
payloadSha256: crypto
.createHash("sha256")
.update(legacyPayloadJson)
.update(unsupportedPayloadJson)
.digest("hex"),
})
.where(eq(projectSnapshots.id, snapshot.id))
.run();
const legacyPrepared = repository.prepareRestore(
"project-1",
snapshot.id
);
assert.ok(legacyPrepared);
assert.equal(
legacyPrepared.command.payload.targetState.project.buildingOwner,
null
assert.throws(
() => repository.prepareRestore("project-1", snapshot.id),
/schema version is not supported/
);
context.db
.update(projectSnapshots)
@@ -474,10 +444,6 @@ describe("portable project transfer", () => {
copied.state.distributionBoards[0].simultaneityFactor,
0.65
);
assert.equal(
copied.state.circuits[0].deviceRows[0].legacyConsumerId,
null
);
} finally {
context.close();
}
@@ -512,52 +478,6 @@ describe("portable project transfer", () => {
}
});
it("imports a checksum-valid version-two project transfer", () => {
const context = createTestDatabase();
try {
const repository = new ProjectTransferRepository(context.db);
const current = repository.exportProject("project-1");
assert.ok(current);
const legacyState = structuredClone(
current.projectState
) as unknown as Record<string, unknown> & {
distributionBoards: Array<Record<string, unknown>>;
};
legacyState.schemaVersion = 2;
removeVersionSevenFields(legacyState);
delete (
legacyState.project as Record<string, unknown>
).enabledDistributionBoardSupplyTypes;
for (const board of legacyState.distributionBoards) {
delete board.floorId;
delete board.supplyType;
delete board.simultaneityFactor;
}
const legacyPayloadJson = JSON.stringify(legacyState);
const duplicate = repository.importDuplicate({
...current,
projectState: legacyState,
payloadSha256: crypto
.createHash("sha256")
.update(legacyPayloadJson)
.digest("hex"),
});
const copied = readProjectStateSnapshot(
context.db,
duplicate.projectId
);
assert.ok(copied);
assert.equal(copied.state.distributionBoards[0].floorId, null);
assert.equal(copied.state.distributionBoards[0].supplyType, null);
assert.equal(
copied.state.distributionBoards[0].simultaneityFactor,
1
);
} finally {
context.close();
}
});
it("rolls back a duplicate when a late project-state insert fails", () => {
const context = createTestDatabase();
try {
@@ -37,12 +37,9 @@ import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { consumers } from "../src/db/schema/consumers.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { floors } from "../src/db/schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
@@ -282,59 +279,6 @@ function changeCompleteProjectState(context: DatabaseContext) {
).createWithCircuitListAndDefaultSections("project-1", "UV-02");
}
function seedUpgradeOnlyData(context: DatabaseContext) {
const circuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(circuit);
context.db
.insert(consumers)
.values({
id: "consumer-1",
projectId: "project-1",
distributionBoardId:
context.db.select().from(circuitLists).get()!
.distributionBoardId,
circuitListId: circuit.circuitListId,
projectDeviceId: "device-1",
roomId: "room-1",
name: "Legacy-Pumpe",
quantity: 1,
installedPowerPerUnitKw: 2.5,
demandFactor: 0.8,
})
.run();
context.db
.insert(legacyConsumerCircuitMigrations)
.values({
consumerId: "consumer-1",
circuitId: "circuit-1",
circuitDeviceRowId: "row-1",
circuitListId: circuit.circuitListId,
createdAtIso: "2026-07-25T00:00:00.000Z",
})
.run();
context.db
.insert(legacyConsumerMigrationReports)
.values({
id: "report-1",
circuitListId: circuit.circuitListId,
legacyConsumerCount: 1,
createdCircuitCount: 1,
createdDeviceRowCount: 1,
duplicateGroupedCount: 0,
generatedIdentifierCount: 0,
unassignedRowCount: 0,
warningsJson: "[]",
generatedIdentifiersJson: "[]",
duplicateGroupsJson: "[]",
createdAtIso: "2026-07-25T00:00:00.000Z",
})
.run();
}
describe("project state restore command", () => {
it("undoes a remapped cross-project import using the persisted target hash", () => {
const context = createTestDatabase();
@@ -360,7 +304,7 @@ describe("project state restore command", () => {
description: "Projektimport wiederherstellen",
command: prepared.command,
});
const legacyExpectedHash = hashProjectStatePayload(
const serializedExpectedHash = hashProjectStatePayload(
serializeProjectStateSnapshot(
prepared.command.payload.targetState
)
@@ -372,7 +316,7 @@ describe("project state restore command", () => {
.get();
assert.ok(storedChangeSet);
const inverse = JSON.parse(storedChangeSet.inversePayloadJson);
inverse.payload.expectedStateSha256 = legacyExpectedHash;
inverse.payload.expectedStateSha256 = serializedExpectedHash;
context.db
.update(projectChangeSets)
.set({ inversePayloadJson: JSON.stringify(inverse) })
@@ -397,7 +341,6 @@ describe("project state restore command", () => {
const context = createTestDatabase();
try {
const snapshots = new ProjectSnapshotRepository(context.db);
seedUpgradeOnlyData(context);
const snapshot = snapshots.createNamed({
projectId: "project-1",
expectedRevision: 0,
@@ -443,28 +386,6 @@ describe("project state restore command", () => {
context.db.select().from(projectSnapshots).all().length,
1
);
assert.equal(
context.db
.select()
.from(legacyConsumerCircuitMigrations)
.all().length,
1
);
assert.equal(
context.db
.select()
.from(legacyConsumerMigrationReports)
.all().length,
1
);
assert.equal(
context.db
.select({ roomId: consumers.roomId })
.from(consumers)
.where(eq(consumers.id, "consumer-1"))
.get()?.roomId,
"room-1"
);
const undone = service.undo({
projectId: "project-1",
+40 -355
View File
@@ -42,17 +42,6 @@ function minimalSnapshot() {
};
}
function legacyMinimalSnapshot() {
const {
distributionBoardComponents: _distributionBoardComponents,
circuitProtectionDevices: _circuitProtectionDevices,
distributionBoardComponentProtectionDevices:
_distributionBoardComponentProtectionDevices,
...snapshot
} = minimalSnapshot();
return snapshot;
}
function protectedSnapshot() {
return {
...minimalSnapshot(),
@@ -94,9 +83,6 @@ function protectedSnapshot() {
equipmentIdentifier: "-1F1.1",
displayName: "Reserve",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
@@ -148,234 +134,26 @@ function protectedSnapshot() {
}
describe("project state snapshot model", () => {
it("round-trips a versioned logical project state", () => {
it("round-trips the current logical project state", () => {
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
assert.deepEqual(
deserializeProjectStateSnapshot(
serializeProjectStateSnapshot(snapshot)
),
deserializeProjectStateSnapshot(serializeProjectStateSnapshot(snapshot)),
snapshot
);
});
it("upgrades version-one snapshots with empty project metadata", () => {
const legacy = {
...legacyMinimalSnapshot(),
schemaVersion: 1 as const,
project: {
id: "project-1",
name: "Altprojekt",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
},
};
const snapshot = parseProjectStateSnapshot(legacy);
assert.equal(snapshot.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(snapshot.project, {
...legacy.project,
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
});
});
it("upgrades version-two distribution boards without inferred assignments", () => {
const previous = {
...legacyMinimalSnapshot(),
schemaVersion: 2 as const,
project: {
...minimalSnapshot().project,
enabledDistributionBoardSupplyTypes: undefined,
},
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
},
],
};
delete previous.project.enabledDistributionBoardSupplyTypes;
const snapshot = parseProjectStateSnapshot(previous);
assert.deepEqual(snapshot.distributionBoards, [
{
...previous.distributionBoards[0],
floorId: null,
supplyType: null,
simultaneityFactor: 1,
},
]);
});
it("upgrades version-three snapshots with all project supply types", () => {
const previous = {
...legacyMinimalSnapshot(),
schemaVersion: 3 as const,
project: {
...minimalSnapshot().project,
enabledDistributionBoardSupplyTypes: undefined,
},
};
delete previous.project.enabledDistributionBoardSupplyTypes;
const snapshot = parseProjectStateSnapshot(previous);
assert.deepEqual(snapshot.project.enabledDistributionBoardSupplyTypes, [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
]);
});
it("normalizes version-four device voltages to project settings", () => {
const previous = {
...legacyMinimalSnapshot(),
schemaVersion: 4 as const,
projectDevices: [
{
id: "device-1",
projectId: "project-1",
name: "Pumpe",
displayName: "Pumpe",
phaseType: "three_phase" as const,
connectionKind: null,
costGroup: null,
category: null,
quantity: 1,
powerPerUnit: 2,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
voltageV: 500,
},
],
};
const snapshot = parseProjectStateSnapshot(previous);
assert.equal(snapshot.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(snapshot.projectDevices[0]?.voltageV, 400);
});
it("upgrades version-five distribution boards with a neutral factor", () => {
const previous = {
...legacyMinimalSnapshot(),
schemaVersion: 5 as const,
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: null,
supplyType: "AV" as const,
},
],
};
const snapshot = parseProjectStateSnapshot(previous);
assert.equal(snapshot.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(
snapshot.distributionBoards[0]?.simultaneityFactor,
1
it("rejects pre-baseline snapshot shapes", () => {
const outdated = structuredClone(minimalSnapshot());
delete (outdated.project as Record<string, unknown>).description;
assert.throws(() => parseProjectStateSnapshot(outdated));
assert.throws(() =>
parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: 7 })
);
});
it("upgrades version-six sections without inventing component data", () => {
const previous = {
...legacyMinimalSnapshot(),
schemaVersion: 6 as const,
circuitSections: [
{
id: "section-lighting",
circuitListId: "list-1",
key: "lighting",
displayName: "Beleuchtung",
prefix: "-1F",
sortOrder: 10,
},
{
id: "section-unassigned",
circuitListId: "list-1",
key: "unassigned",
displayName: "Nicht zugeordnet",
prefix: "-UF",
sortOrder: 90,
},
],
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: null,
supplyType: null,
simultaneityFactor: 1,
},
],
circuitLists: [
{
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV 1 Stromkreisliste",
},
],
};
const snapshot = parseProjectStateSnapshot(previous);
assert.deepEqual(
snapshot.circuitSections.map(
({ key, category, groupNumber }) => ({
key,
category,
groupNumber,
})
),
[
{
key: "lighting",
category: "lighting",
groupNumber: 1,
},
{
key: "unassigned",
category: null,
groupNumber: null,
},
]
);
assert.deepEqual(snapshot.distributionBoardComponents, []);
assert.deepEqual(snapshot.circuitProtectionDevices, []);
assert.deepEqual(
snapshot.distributionBoardComponentProtectionDevices,
[]
);
});
it("validates the complete group, component and protection graph", () => {
it("validates component, protection and equipment relations", () => {
const snapshot = parseProjectStateSnapshot(protectedSnapshot());
assert.equal(snapshot.circuitSections[0]?.groupNumber, 1);
assert.equal(
snapshot.distributionBoardComponents[0]?.sectionId,
"section-1"
);
assert.equal(
snapshot.circuitProtectionDevices[0]?.tripCharacteristic,
"B"
);
assert.equal(snapshot.circuitProtectionDevices[0]?.tripCharacteristic, "B");
assert.equal(
snapshot.distributionBoardComponentProtectionDevices[0]
?.ratedResidualCurrentMa,
@@ -401,113 +179,27 @@ describe("project state snapshot model", () => {
);
});
it("rejects duplicate ids and cross-project ownership", () => {
it("rejects duplicate ids and invalid ownership", () => {
const duplicate = protectedSnapshot();
duplicate.distributionBoards.push({
...duplicate.distributionBoards[0]!,
name: "UV 2",
});
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: null,
supplyType: null,
simultaneityFactor: 1,
},
{
id: "board-1",
projectId: "project-1",
name: "UV 2",
floorId: null,
supplyType: null,
simultaneityFactor: 1,
},
],
}),
() => parseProjectStateSnapshot(duplicate),
/duplicate distribution board ids/
);
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
floors: [
{
id: "floor-1",
projectId: "project-2",
name: "EG",
sortOrder: 10,
},
],
}),
/floor belongs to a different project/
);
});
it("rejects invalid references and inconsistent reserve circuits", () => {
const wrongOwner = minimalSnapshot();
wrongOwner.floors.push({
id: "floor-1",
projectId: "project-2",
name: "EG",
sortOrder: 10,
});
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: "missing",
supplyType: "AV",
simultaneityFactor: 1,
},
],
}),
/distribution board floor reference is invalid/
);
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
rooms: [
{
id: "room-1",
projectId: "project-1",
floorId: "missing",
roomNumber: "001",
roomName: "Technik",
},
],
}),
/room floor reference is invalid/
);
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
circuits: [
{
id: "circuit-1",
circuitListId: "missing",
sectionId: "missing",
equipmentIdentifier: "-1F1",
displayName: null,
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: null,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
},
],
}),
/circuit list reference is invalid/
() => parseProjectStateSnapshot(wrongOwner),
/floor belongs to a different project/
);
});
});
@@ -518,36 +210,29 @@ describe("named project snapshot request", () => {
createNamedProjectSnapshotSchema.parse({
expectedRevision: 12,
name: " Freigabe ",
description: " Stand vor Ausschreibung ",
description: " Stand vor Änderung ",
}),
{
expectedRevision: 12,
name: "Freigabe",
description: "Stand vor Ausschreibung",
description: "Stand vor Änderung",
}
);
});
it("rejects stale-shaped and excessive input", () => {
for (const input of [
{ expectedRevision: -1, name: "Stand" },
{ expectedRevision: 0, name: " " },
{ expectedRevision: 0, name: "x".repeat(101) },
{
expectedRevision: 0,
name: "Stand",
description: "x".repeat(501),
},
{
expectedRevision: 0,
name: "Stand",
assert.throws(() =>
createNamedProjectSnapshotSchema.parse({
expectedRevision: -1,
name: "",
unexpected: true,
},
]) {
assert.equal(
createNamedProjectSnapshotSchema.safeParse(input).success,
false
);
}
})
);
assert.throws(() =>
createNamedProjectSnapshotSchema.parse({
expectedRevision: 0,
name: "x".repeat(201),
})
);
});
});