forked from jappel/leistungsbilanz-ts
A circuit with exactly one device row and no fed distribution board is shown as that row, so its own name was hidden and could drift (e.g. "Neuer Stromkreis" in the print view). Commands that create or leave such a circuit, rename its row or unlink its downstream board now set the circuit name to the row name and store the change as optional circuitDisplayNames, so Undo restores an overwritten free name exactly. Migration 0016 aligns existing data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
516 lines
19 KiB
TypeScript
516 lines
19 KiB
TypeScript
import path from "node:path";
|
|
import assert from "node:assert/strict";
|
|
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
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 { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js";
|
|
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
|
|
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
|
|
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
|
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
|
import { projects } from "../src/db/schema/projects.js";
|
|
import { resolveSingleDeviceCircuitDisplayName } from "../src/domain/models/circuit-display-name-sync.model.js";
|
|
import { createCircuitDeviceRowMoveWithNewCircuitProjectCommand } from "../src/domain/models/circuit-device-row-move-project-command.model.js";
|
|
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
|
import {
|
|
createCircuitDeviceRowDeleteProjectCommand,
|
|
createCircuitDeviceRowInsertProjectCommand,
|
|
type CircuitDeviceRowSnapshot,
|
|
} from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
|
import {
|
|
createCircuitInsertProjectCommand,
|
|
type CircuitSnapshot,
|
|
} from "../src/domain/models/circuit-structure-project-command.model.js";
|
|
import { createDistributionBoardUpdateProjectCommand } from "../src/domain/models/distribution-board-project-command.model.js";
|
|
import type { SerializedProjectCommand } from "../src/domain/models/project-command.model.js";
|
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
|
|
|
interface Fixture {
|
|
context: DatabaseContext;
|
|
boardId: string;
|
|
circuitListId: string;
|
|
sectionId: string;
|
|
}
|
|
|
|
function createFixture(): Fixture {
|
|
const context = createDatabaseContext(":memory:");
|
|
migrate(context.db, {
|
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
|
});
|
|
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
|
const boards = new DistributionBoardFixtureRepository(context.db);
|
|
const board = boards.createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
|
const section = context.db
|
|
.select()
|
|
.from(circuitSections)
|
|
.where(eq(circuitSections.circuitListId, board.id))
|
|
.get();
|
|
assert.ok(section);
|
|
return {
|
|
context,
|
|
boardId: board.id,
|
|
circuitListId: section.circuitListId,
|
|
sectionId: section.id,
|
|
};
|
|
}
|
|
|
|
function insertCircuit(
|
|
fixture: Fixture,
|
|
id: string,
|
|
displayName: string | null,
|
|
rowNames: string[],
|
|
sortOrder = 10
|
|
) {
|
|
fixture.context.db
|
|
.insert(circuits)
|
|
.values({
|
|
id,
|
|
circuitListId: fixture.circuitListId,
|
|
sectionId: fixture.sectionId,
|
|
equipmentIdentifier: `-1F${sortOrder}`,
|
|
displayName,
|
|
sortOrder,
|
|
voltage: 230,
|
|
isReserve: rowNames.length === 0 ? 1 : 0,
|
|
})
|
|
.run();
|
|
rowNames.forEach((rowName, index) => {
|
|
fixture.context.db
|
|
.insert(circuitDeviceRows)
|
|
.values(rowValues(`${id}-row-${index + 1}`, id, rowName, (index + 1) * 10))
|
|
.run();
|
|
});
|
|
}
|
|
|
|
function rowValues(
|
|
id: string,
|
|
circuitId: string,
|
|
displayName: string,
|
|
sortOrder: number
|
|
): CircuitDeviceRowSnapshot {
|
|
return {
|
|
id,
|
|
circuitId,
|
|
linkedProjectDeviceId: null,
|
|
sortOrder,
|
|
name: displayName,
|
|
displayName,
|
|
phaseType: "single_phase",
|
|
connectionKind: null,
|
|
costGroup: null,
|
|
category: null,
|
|
level: null,
|
|
roomId: null,
|
|
roomNumberSnapshot: null,
|
|
roomNameSnapshot: null,
|
|
quantity: 1,
|
|
manualQuantity: 1,
|
|
powerPerUnit: 0.1,
|
|
simultaneityFactor: 1,
|
|
cosPhi: null,
|
|
remark: null,
|
|
overriddenFields: null,
|
|
};
|
|
}
|
|
|
|
function circuitName(fixture: Fixture, circuitId: string) {
|
|
return fixture.context.db
|
|
.select({ displayName: circuits.displayName })
|
|
.from(circuits)
|
|
.where(eq(circuits.id, circuitId))
|
|
.get()?.displayName;
|
|
}
|
|
|
|
interface HistoryStore {
|
|
execute(input: {
|
|
projectId: string;
|
|
expectedRevision: number;
|
|
source: "user" | "undo" | "redo";
|
|
historyTargetChangeSetId?: string;
|
|
command: never;
|
|
}): unknown;
|
|
}
|
|
|
|
/** Replays the stored history step like the project command service does. */
|
|
function replay(
|
|
fixture: Fixture,
|
|
store: HistoryStore,
|
|
direction: "undo" | "redo"
|
|
) {
|
|
const history = new ProjectHistoryRepository(fixture.context.db);
|
|
const step = history.getNextCommand("project-1", direction);
|
|
assert.ok(step);
|
|
store.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: history.getState("project-1")!.currentRevision,
|
|
source: direction,
|
|
historyTargetChangeSetId: step.changeSetId,
|
|
command: step.command as SerializedProjectCommand<unknown> as never,
|
|
});
|
|
}
|
|
|
|
function currentRevision(fixture: Fixture) {
|
|
return new ProjectHistoryRepository(fixture.context.db).getState("project-1")!
|
|
.currentRevision;
|
|
}
|
|
|
|
describe("single-device circuit name", () => {
|
|
it("follows the only device row unless a distribution board is fed", () => {
|
|
const resolve = (
|
|
displayName: string | null,
|
|
deviceRowDisplayNames: string[],
|
|
feedsDistributionBoard = false
|
|
) =>
|
|
resolveSingleDeviceCircuitDisplayName({
|
|
displayName,
|
|
deviceRowDisplayNames,
|
|
feedsDistributionBoard,
|
|
});
|
|
assert.equal(resolve("Neuer Stromkreis", ["Ersatzlast"]), "Ersatzlast");
|
|
assert.equal(resolve(null, ["Ersatzlast"]), "Ersatzlast");
|
|
assert.equal(resolve("Ersatzlast", ["Ersatzlast"]), null);
|
|
assert.equal(resolve("Frei", ["A", "B"]), null);
|
|
assert.equal(resolve("Reserve", []), null);
|
|
assert.equal(resolve("Zuleitung UV", ["A"], true), null);
|
|
});
|
|
|
|
it("names a new single-device circuit after its row and keeps it on undo/redo", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const store = new CircuitStructureProjectCommandRepository(fixture.context.db);
|
|
const snapshot: CircuitSnapshot = {
|
|
id: "circuit-new",
|
|
circuitListId: fixture.circuitListId,
|
|
sectionId: fixture.sectionId,
|
|
equipmentIdentifier: "-1F1",
|
|
displayName: "Neuer Stromkreis",
|
|
sortOrder: 10,
|
|
cableType: null,
|
|
cableCrossSection: null,
|
|
cableLength: null,
|
|
rcdAssignment: null,
|
|
terminalDesignation: null,
|
|
voltage: 230,
|
|
controlRequirement: null,
|
|
status: null,
|
|
isReserve: false,
|
|
remark: null,
|
|
deviceRows: [rowValues("row-new", "circuit-new", "Ersatzlast", 10)],
|
|
};
|
|
store.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command: createCircuitInsertProjectCommand(snapshot),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-new"), "Ersatzlast");
|
|
|
|
replay(fixture, store, "undo");
|
|
assert.equal(circuitName(fixture, "circuit-new"), undefined);
|
|
replay(fixture, store, "redo");
|
|
assert.equal(circuitName(fixture, "circuit-new"), "Ersatzlast");
|
|
} finally {
|
|
fixture.context.close();
|
|
}
|
|
});
|
|
|
|
it("renames the circuit with its only row and restores both on undo", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
insertCircuit(fixture, "circuit-1", "Neuer Stromkreis", ["Gerät"]);
|
|
const store = new CircuitDeviceRowProjectCommandRepository(fixture.context.db);
|
|
store.executeUpdate({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command: createCircuitDeviceRowUpdateProjectCommand("circuit-1-row-1", {
|
|
displayName: "Ersatzlast",
|
|
}),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Ersatzlast");
|
|
|
|
replay(fixture, { execute: (input) => store.executeUpdate(input) }, "undo");
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Neuer Stromkreis");
|
|
replay(fixture, { execute: (input) => store.executeUpdate(input) }, "redo");
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Ersatzlast");
|
|
} finally {
|
|
fixture.context.close();
|
|
}
|
|
});
|
|
|
|
it("leaves a multi-device circuit name free and does not touch it for other row edits", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
insertCircuit(fixture, "circuit-1", "Frei", ["A", "B"]);
|
|
const store = new CircuitDeviceRowProjectCommandRepository(fixture.context.db);
|
|
store.executeUpdate({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command: createCircuitDeviceRowUpdateProjectCommand("circuit-1-row-1", {
|
|
displayName: "A neu",
|
|
}),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Frei");
|
|
|
|
insertCircuit(fixture, "circuit-2", "Bewusst abweichend", ["C"], 20);
|
|
store.executeUpdate({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
source: "user",
|
|
command: createCircuitDeviceRowUpdateProjectCommand("circuit-2-row-1", {
|
|
quantity: 2,
|
|
}),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-2"), "Bewusst abweichend");
|
|
} finally {
|
|
fixture.context.close();
|
|
}
|
|
});
|
|
|
|
it("takes the remaining row name after a delete and restores the free name on undo", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
insertCircuit(fixture, "circuit-1", "Gerät A", ["Gerät A"]);
|
|
const store = new CircuitDeviceRowStructureProjectCommandRepository(fixture.context.db);
|
|
store.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command: createCircuitDeviceRowInsertProjectCommand(
|
|
rowValues("row-b", "circuit-1", "Gerät B", 20)
|
|
),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät A");
|
|
|
|
new CircuitProjectCommandRepository(fixture.context.db).executeUpdate({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
source: "user",
|
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
|
displayName: "Küche",
|
|
}),
|
|
});
|
|
store.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 2,
|
|
source: "user",
|
|
command: createCircuitDeviceRowDeleteProjectCommand("circuit-1-row-1", "circuit-1"),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät B");
|
|
|
|
replay(fixture, store, "undo");
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Küche");
|
|
replay(fixture, store, "redo");
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät B");
|
|
assert.equal(currentRevision(fixture), 5);
|
|
} finally {
|
|
fixture.context.close();
|
|
}
|
|
});
|
|
|
|
it("keeps stored legacy commands without name changes executable", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
insertCircuit(fixture, "circuit-1", "Küche", ["Gerät A", "Gerät B"]);
|
|
const store = new CircuitDeviceRowStructureProjectCommandRepository(fixture.context.db);
|
|
store.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command: createCircuitDeviceRowDeleteProjectCommand("circuit-1-row-1", "circuit-1"),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät B");
|
|
const history = new ProjectHistoryRepository(fixture.context.db);
|
|
const step = history.getNextCommand("project-1", "undo");
|
|
assert.ok(step);
|
|
const legacyInverse = structuredClone(step.command) as SerializedProjectCommand<{
|
|
circuitDisplayNames?: unknown;
|
|
}>;
|
|
delete legacyInverse.payload.circuitDisplayNames;
|
|
store.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
source: "undo",
|
|
historyTargetChangeSetId: step.changeSetId,
|
|
command: legacyInverse as never,
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät B");
|
|
} finally {
|
|
fixture.context.close();
|
|
}
|
|
});
|
|
|
|
it("names a new circuit after its single moved row and syncs the source circuit", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
insertCircuit(fixture, "circuit-1", "Küche", ["Gerät A", "Gerät B"]);
|
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(fixture.context.db);
|
|
store.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command: createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
|
"create",
|
|
{
|
|
id: "circuit-new",
|
|
circuitListId: fixture.circuitListId,
|
|
sectionId: fixture.sectionId,
|
|
equipmentIdentifier: "-1F99",
|
|
displayName: "Neuer Stromkreis",
|
|
sortOrder: 99,
|
|
cableType: null,
|
|
cableCrossSection: null,
|
|
cableLength: null,
|
|
rcdAssignment: null,
|
|
terminalDesignation: null,
|
|
voltage: null,
|
|
controlRequirement: null,
|
|
status: null,
|
|
isReserve: true,
|
|
remark: null,
|
|
deviceRows: [],
|
|
},
|
|
[
|
|
{
|
|
rowId: "circuit-1-row-2",
|
|
expectedCircuitId: "circuit-1",
|
|
expectedSortOrder: 20,
|
|
targetCircuitId: "circuit-new",
|
|
targetSortOrder: 10,
|
|
},
|
|
]
|
|
),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-new"), "Gerät B");
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät A");
|
|
|
|
replay(fixture, store, "undo");
|
|
assert.equal(circuitName(fixture, "circuit-new"), undefined);
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Küche");
|
|
replay(fixture, store, "redo");
|
|
assert.equal(circuitName(fixture, "circuit-new"), "Gerät B");
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät A");
|
|
} finally {
|
|
fixture.context.close();
|
|
}
|
|
});
|
|
|
|
it("does not rename a feeder circuit until its downstream board is unlinked", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
insertCircuit(fixture, "circuit-1", "Zuleitung UV-02", ["Gerät A"]);
|
|
const boards = new DistributionBoardFixtureRepository(fixture.context.db);
|
|
const downstream = boards.createWithCircuitListAndDefaultSections("project-1", "UV-02");
|
|
fixture.context.db
|
|
.update(distributionBoards)
|
|
.set({ feedingDistributionBoardId: fixture.boardId, feedingCircuitId: "circuit-1" })
|
|
.where(eq(distributionBoards.id, downstream.id))
|
|
.run();
|
|
|
|
const rowStore = new CircuitDeviceRowProjectCommandRepository(fixture.context.db);
|
|
rowStore.executeUpdate({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command: createCircuitDeviceRowUpdateProjectCommand("circuit-1-row-1", {
|
|
displayName: "Gerät neu",
|
|
}),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Zuleitung UV-02");
|
|
|
|
const boardStore = new DistributionBoardStructureProjectCommandRepository(fixture.context.db);
|
|
const execute = (input: Parameters<HistoryStore["execute"]>[0]) =>
|
|
boardStore.executeUpdate(input);
|
|
boardStore.executeUpdate({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
source: "user",
|
|
command: createDistributionBoardUpdateProjectCommand(downstream.id, {
|
|
feedingDistributionBoardId: null,
|
|
feedingCircuitId: null,
|
|
}),
|
|
});
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät neu");
|
|
|
|
replay(fixture, { execute }, "undo");
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Zuleitung UV-02");
|
|
replay(fixture, { execute }, "redo");
|
|
assert.equal(circuitName(fixture, "circuit-1"), "Gerät neu");
|
|
} finally {
|
|
fixture.context.close();
|
|
}
|
|
});
|
|
|
|
it("aligns existing single-device circuit names in migration 0016", () => {
|
|
const source = path.resolve("src", "db", "migrations");
|
|
const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-circuit-name-migration-"));
|
|
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
|
|
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 15);
|
|
mkdirSync(path.join(staging, "meta"));
|
|
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
|
|
for (const entry of journal.entries) {
|
|
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
|
|
}
|
|
|
|
const context = createDatabaseContext(":memory:");
|
|
try {
|
|
migrate(context.db, { migrationsFolder: staging });
|
|
context.sqlite.exec(`
|
|
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
|
|
INSERT INTO distribution_boards (id, project_id, name) VALUES ('b', 'p', 'UV');
|
|
INSERT INTO circuit_lists (id, project_id, distribution_board_id, name) VALUES ('l', 'p', 'b', 'Liste');
|
|
INSERT INTO circuit_sections (id, circuit_list_id, key, display_name, prefix) VALUES ('s', 'l', 'single_phase', 'Einphasig', '-2F1.');
|
|
INSERT INTO circuits (id, circuit_list_id, section_id, equipment_identifier, display_name) VALUES
|
|
('single', 'l', 's', '-2F1.1', 'Neuer Stromkreis'),
|
|
('unnamed', 'l', 's', '-2F1.2', NULL),
|
|
('multi', 'l', 's', '-2F1.3', 'Küche'),
|
|
('reserve', 'l', 's', '-2F1.4', 'Reserve'),
|
|
('feeder', 'l', 's', '-2F1.5', 'Zuleitung UV-02');
|
|
INSERT INTO circuit_device_rows
|
|
(id, circuit_id, name, display_name, quantity, manual_quantity, power_per_unit, simultaneity_factor)
|
|
VALUES
|
|
('r1', 'single', 'Gerät', 'Ersatzlast', 1, 1, 1, 1),
|
|
('r2', 'unnamed', 'Gerät', 'Leuchte', 1, 1, 1, 1),
|
|
('r3', 'multi', 'Gerät', 'A', 1, 1, 1, 1),
|
|
('r4', 'multi', 'Gerät', 'B', 1, 1, 1, 1),
|
|
('r5', 'feeder', 'Gerät', 'Pumpe', 1, 1, 1, 1);
|
|
INSERT INTO distribution_boards (id, project_id, name, feeding_distribution_board_id, feeding_circuit_id)
|
|
VALUES ('b2', 'p', 'UV-02', 'b', 'feeder');
|
|
`);
|
|
|
|
migrate(context.db, { migrationsFolder: source });
|
|
|
|
const names = Object.fromEntries(
|
|
(context.sqlite.prepare("SELECT id, display_name FROM circuits").all() as Array<{
|
|
id: string;
|
|
display_name: string | null;
|
|
}>).map((row) => [row.id, row.display_name])
|
|
);
|
|
assert.deepEqual(names, {
|
|
single: "Ersatzlast",
|
|
unnamed: "Leuchte",
|
|
multi: "Küche",
|
|
reserve: "Reserve",
|
|
feeder: "Zuleitung UV-02",
|
|
});
|
|
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
|
|
} finally {
|
|
context.close();
|
|
rmSync(staging, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|