Add component persistence foundation

This commit is contained in:
2026-07-30 19:15:00 +02:00
parent a88c4b2086
commit e7607c90c4
15 changed files with 3146 additions and 12 deletions
@@ -0,0 +1,466 @@
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();
}
});
});