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(); } }); });