Ctrl+C/Ctrl+V copy and paste grid cells without opening an editor, using the document copy/paste events so it also works over plain HTTP in the LAN. Protection and cable summaries copy the complete circuit record; tab-separated blocks from Excel spread over visible rows and columns. With a Ctrl/Shift multi-selection, typing, pasting and the protection dialog apply to every selected row that can take the value, including device power and GZF; other cells are skipped and reported. All of these writes use the new circuit-list.bulk-update command. It carries complete circuit.update, circuit-device-row.update and circuit-protection.update commands, applies them with the single-command rules in one transaction and stores the reversed inverses, so each edit is one undo step. The single-command apply logic is extracted for reuse. A right-click menu (also context-menu key / Shift+F10) offers copy, paste, edit, protection, cable sizing, insert circuit below, add device and delete. The protection summary is now selected by click and opened by double click, Enter or F2. Existing numbers open in the inline editor with a decimal comma, so confirming an unchanged decimal no longer fails. Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
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 { CircuitListBulkUpdateProjectCommandRepository } from "../src/db/repositories/circuit-list-bulk-update-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 { 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 { projects } from "../src/db/schema/projects.js";
|
|
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
|
import {
|
|
assertCircuitListBulkUpdateProjectCommand,
|
|
createCircuitListBulkUpdateProjectCommand,
|
|
} from "../src/domain/models/circuit-list-bulk-update-project-command.model.js";
|
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
|
import {
|
|
createCircuitProtectionUpdateProjectCommand,
|
|
type CircuitProtectionSnapshot,
|
|
} from "../src/domain/models/circuit-protection-project-command.model.js";
|
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
|
|
|
function createTestDatabase(): DatabaseContext {
|
|
const context = createDatabaseContext(":memory:");
|
|
migrate(context.db, {
|
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
|
});
|
|
context.db
|
|
.insert(projects)
|
|
.values([
|
|
{ id: "project-1", name: "Projekt" },
|
|
{ id: "project-2", name: "Fremdprojekt" },
|
|
])
|
|
.run();
|
|
const fixtures = new DistributionBoardFixtureRepository(context.db);
|
|
fixtures.createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
|
fixtures.createWithCircuitListAndDefaultSections("project-2", "UV-02");
|
|
const [list, foreignList] = ["project-1", "project-2"].map((projectId) => {
|
|
const circuitList = context.db
|
|
.select()
|
|
.from(circuitLists)
|
|
.where(eq(circuitLists.projectId, projectId))
|
|
.get();
|
|
assert.ok(circuitList);
|
|
return circuitList;
|
|
});
|
|
const sectionOf = (circuitListId: string) => {
|
|
const section = context.db
|
|
.select()
|
|
.from(circuitSections)
|
|
.where(eq(circuitSections.circuitListId, circuitListId))
|
|
.get();
|
|
assert.ok(section);
|
|
return section.id;
|
|
};
|
|
context.db
|
|
.insert(circuits)
|
|
.values([
|
|
{
|
|
id: "circuit-1",
|
|
circuitListId: list.id,
|
|
sectionId: sectionOf(list.id),
|
|
equipmentIdentifier: "-1F1",
|
|
displayName: "Leuchte",
|
|
sortOrder: 10,
|
|
},
|
|
{
|
|
id: "circuit-2",
|
|
circuitListId: list.id,
|
|
sectionId: sectionOf(list.id),
|
|
equipmentIdentifier: "-1F2",
|
|
displayName: "Leuchte",
|
|
sortOrder: 20,
|
|
},
|
|
{
|
|
id: "foreign-circuit",
|
|
circuitListId: foreignList.id,
|
|
sectionId: sectionOf(foreignList.id),
|
|
equipmentIdentifier: "-1F1",
|
|
sortOrder: 10,
|
|
},
|
|
])
|
|
.run();
|
|
context.db
|
|
.insert(circuitDeviceRows)
|
|
.values(
|
|
["circuit-1", "circuit-2"].map((circuitId, index) => ({
|
|
id: `row-${index + 1}`,
|
|
circuitId,
|
|
sortOrder: 10,
|
|
name: "Leuchte",
|
|
displayName: "Leuchte",
|
|
quantity: 2,
|
|
manualQuantity: 2,
|
|
powerPerUnit: 0.1,
|
|
simultaneityFactor: 1,
|
|
}))
|
|
)
|
|
.run();
|
|
context.db
|
|
.insert(circuitProtectionDevices)
|
|
.values(protectionFor("circuit-1", 10))
|
|
.run();
|
|
return context;
|
|
}
|
|
|
|
function protectionFor(
|
|
circuitId: string,
|
|
ratedCurrentA: number
|
|
): CircuitProtectionSnapshot {
|
|
return {
|
|
circuitId,
|
|
type: "LS",
|
|
ratedCurrentA,
|
|
fuseUtilizationCategory: null,
|
|
tripCharacteristic: "B",
|
|
rcdType: null,
|
|
ratedResidualCurrentMa: null,
|
|
};
|
|
}
|
|
|
|
function readState(context: DatabaseContext) {
|
|
return {
|
|
circuits: context.db
|
|
.select({
|
|
id: circuits.id,
|
|
displayName: circuits.displayName,
|
|
cableType: circuits.cableType,
|
|
cableCrossSection: circuits.cableCrossSection,
|
|
})
|
|
.from(circuits)
|
|
.all(),
|
|
rows: context.db
|
|
.select({
|
|
id: circuitDeviceRows.id,
|
|
displayName: circuitDeviceRows.displayName,
|
|
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
|
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
|
})
|
|
.from(circuitDeviceRows)
|
|
.all(),
|
|
protections: context.db.select().from(circuitProtectionDevices).all(),
|
|
};
|
|
}
|
|
|
|
describe("circuit-list bulk update project command", () => {
|
|
it("applies circuit, device-row and protection edits as one undoable revision", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const repository = new CircuitListBulkUpdateProjectCommandRepository(
|
|
context.db
|
|
);
|
|
const history = new ProjectHistoryRepository(context.db);
|
|
const before = readState(context);
|
|
|
|
const command = createCircuitListBulkUpdateProjectCommand([
|
|
createCircuitUpdateProjectCommand("circuit-1", {
|
|
cableType: "NYM-J",
|
|
cableCrossSection: "3x1,5",
|
|
}),
|
|
createCircuitUpdateProjectCommand("circuit-2", {
|
|
cableType: "NYM-J",
|
|
cableCrossSection: "3x1,5",
|
|
}),
|
|
createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
|
powerPerUnit: 0.25,
|
|
simultaneityFactor: 0.8,
|
|
}),
|
|
createCircuitDeviceRowUpdateProjectCommand("row-2", {
|
|
displayName: "Steckdose",
|
|
}),
|
|
createCircuitProtectionUpdateProjectCommand(
|
|
"circuit-1",
|
|
protectionFor("circuit-1", 10),
|
|
protectionFor("circuit-1", 16)
|
|
),
|
|
createCircuitProtectionUpdateProjectCommand(
|
|
"circuit-2",
|
|
null,
|
|
protectionFor("circuit-2", 16)
|
|
),
|
|
]);
|
|
const executed = repository.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command,
|
|
});
|
|
|
|
assert.equal(history.getState("project-1")?.currentRevision, 1);
|
|
assert.equal(history.getState("project-1")?.undoDepth, 1);
|
|
const after = readState(context);
|
|
assert.deepEqual(
|
|
after.circuits
|
|
.filter((circuit) => circuit.id !== "foreign-circuit")
|
|
.map((circuit) => [circuit.cableType, circuit.cableCrossSection]),
|
|
[
|
|
["NYM-J", "3x1,5"],
|
|
["NYM-J", "3x1,5"],
|
|
]
|
|
);
|
|
// The single-device circuit follows its renamed row.
|
|
assert.equal(
|
|
after.circuits.find((circuit) => circuit.id === "circuit-2")
|
|
?.displayName,
|
|
"Steckdose"
|
|
);
|
|
assert.deepEqual(
|
|
after.rows.find((row) => row.id === "row-1"),
|
|
{
|
|
id: "row-1",
|
|
displayName: "Leuchte",
|
|
powerPerUnit: 0.25,
|
|
simultaneityFactor: 0.8,
|
|
}
|
|
);
|
|
assert.deepEqual(
|
|
after.protections.map((protection) => protection.ratedCurrentA),
|
|
[16, 16]
|
|
);
|
|
// The inverse undoes the updates in reverse order.
|
|
assert.deepEqual(
|
|
executed.inverse.payload.updates.map((update) => update.type),
|
|
[
|
|
"circuit-protection.update",
|
|
"circuit-protection.update",
|
|
"circuit-device-row.update",
|
|
"circuit-device-row.update",
|
|
"circuit.update",
|
|
"circuit.update",
|
|
]
|
|
);
|
|
|
|
const undo = history.getNextCommand("project-1", "undo");
|
|
assert.ok(undo);
|
|
repository.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
source: "undo",
|
|
historyTargetChangeSetId: undo.changeSetId,
|
|
command: executed.inverse,
|
|
});
|
|
assert.deepEqual(readState(context), before);
|
|
|
|
const redo = history.getNextCommand("project-1", "redo");
|
|
assert.ok(redo);
|
|
repository.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 2,
|
|
source: "redo",
|
|
historyTargetChangeSetId: redo.changeSetId,
|
|
command: (() => {
|
|
assertCircuitListBulkUpdateProjectCommand(redo.command);
|
|
return redo.command;
|
|
})(),
|
|
});
|
|
assert.deepEqual(readState(context), after);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("rolls back every update when one update is rejected", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const repository = new CircuitListBulkUpdateProjectCommandRepository(
|
|
context.db
|
|
);
|
|
const history = new ProjectHistoryRepository(context.db);
|
|
const before = readState(context);
|
|
const valid = createCircuitUpdateProjectCommand("circuit-1", {
|
|
cableType: "NYM-J",
|
|
});
|
|
|
|
for (const rejected of [
|
|
createCircuitUpdateProjectCommand("foreign-circuit", {
|
|
cableType: "NYM-J",
|
|
}),
|
|
createCircuitProtectionUpdateProjectCommand(
|
|
"circuit-1",
|
|
protectionFor("circuit-1", 13),
|
|
protectionFor("circuit-1", 16)
|
|
),
|
|
]) {
|
|
assert.throws(
|
|
() =>
|
|
repository.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
source: "user",
|
|
command: createCircuitListBulkUpdateProjectCommand([
|
|
valid,
|
|
rejected,
|
|
]),
|
|
}),
|
|
/does not belong to project|changed before update/
|
|
);
|
|
assert.deepEqual(readState(context), before);
|
|
assert.equal(history.getState("project-1")?.currentRevision, 0);
|
|
}
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("rejects empty, duplicate and unsupported nested updates", () => {
|
|
const update = createCircuitUpdateProjectCommand("circuit-1", {
|
|
cableType: "NYM-J",
|
|
});
|
|
assert.throws(
|
|
() => createCircuitListBulkUpdateProjectCommand([]),
|
|
/requires 1 to/
|
|
);
|
|
assert.throws(
|
|
() => createCircuitListBulkUpdateProjectCommand([update, update]),
|
|
/one target twice/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertCircuitListBulkUpdateProjectCommand({
|
|
schemaVersion: 1,
|
|
type: "circuit-list.bulk-update",
|
|
payload: {
|
|
updates: [
|
|
{ schemaVersion: 1, type: "circuit.delete", payload: {} },
|
|
],
|
|
},
|
|
}),
|
|
/unsupported update/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertCircuitListBulkUpdateProjectCommand({
|
|
schemaVersion: 1,
|
|
type: "circuit-list.bulk-update",
|
|
payload: { updates: [update], extra: true },
|
|
}),
|
|
/Unsupported circuit-list bulk update/
|
|
);
|
|
});
|
|
});
|