Files
leistungsbilanz-ts/tests/external-object-row-assignment-project-command.repository.test.ts

574 lines
22 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 AppDatabase } from "../src/db/database-context.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js";
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js";
import { floors } from "../src/db/schema/floors.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js";
import {
createExternalObjectNewRowProjectCommand,
externalObjectAssignToNewRowCommandType,
} from "../src/domain/models/external-object-new-row-project-command.model.js";
import {
createExternalObjectNewCircuitProjectCommand,
externalObjectAssignToNewCircuitCommandType,
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
function createFixture(initialObjectRowId: string | null = "row-1") {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
const database = context.db;
database.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
database.insert(floors).values({ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 10 }).run();
database.insert(rooms).values({ id: "room-1", projectId: "project-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro" }).run();
database.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run();
database.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run();
database.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run();
for (const index of [1, 2]) {
database.insert(circuits).values({ id: `circuit-${index}`, circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: `-2F1.${index}`, sortOrder: index * 10, voltage: 230 }).run();
database.insert(circuitDeviceRows).values({
id: `row-${index}`,
circuitId: `circuit-${index}`,
sortOrder: 10,
name: "socket",
displayName: "Steckdose",
phaseType: "single_phase",
connectionKind: "socket",
category: "single_phase",
roomId: "room-1",
roomNumberSnapshot: "101",
roomNameSnapshot: "Büro",
quantity: index === 1 ? (initialObjectRowId === "row-1" ? 3 : 1) : 0,
manualQuantity: index === 1 ? 1 : 0,
powerPerUnit: 0.12,
simultaneityFactor: 1,
}).run();
}
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run();
database.insert(externalImportBatches).values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationVersion: 1,
configurationSnapshot: externalCsvTestConfiguration,
originalBytes: Buffer.from("test"),
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
}).run();
database.insert(externalRoomMappings).values({
id: "mapping-1",
projectId: "project-1",
sourceId: "source-1",
normalizedSourceRoomKey: "number:101",
sourceFloorName: "EG",
sourceRoomNumber: "101",
sourceRoomName: "Büro",
roomId: "room-1",
defaultDistributionBoardId: "board-1",
}).run();
database.insert(externalModelObjects).values(objectSnapshot(initialObjectRowId)).run();
return context;
}
function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectSnapshot {
return {
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
ifcGuid: "ifc-1",
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
rowNumber: 2,
roomNumber: "101",
roomName: "Büro",
familyAndType: "Steckdose: Standard",
selectionMarker: "Steckdose",
circuitIdentifier: "-2F1.1",
power: "120",
quantity: "2",
additionalSourceValues: {},
},
planningValues: {
displayName: "Steckdose",
internalDeviceType: "socket",
category: "single_phase",
connectionKind: "socket",
effectiveQuantity: 2,
powerPerUnitW: 120,
simultaneityFactor: 1,
cosPhi: null,
costGroup: null,
remark: null,
},
overriddenFields: [],
externalRoomMappingId: "mapping-1",
distributionBoardId: "board-1",
linkedProjectDeviceId: null,
circuitDeviceRowId,
presenceStatus: "present",
};
}
function row(database: AppDatabase, id: string) {
return toCircuitDeviceRowSnapshot(
database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, id)).get()!
);
}
function moveCommand(database: AppDatabase, confirmedConflictObjectIds: string[] = []) {
const row1 = row(database, "row-1");
const row2 = row(database, "row-2");
const object = objectSnapshot("row-1");
return createExternalObjectRowAssignmentProjectCommand({
rows: [
{ expected: row1, target: { ...row1, quantity: 1 } },
{ expected: row2, target: { ...row2, quantity: 2 } },
],
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-2" } }],
confirmedConflictObjectIds,
});
}
describe("external object row assignment project command", () => {
it("moves an object atomically and preserves exact quantities through undo and redo", () => {
const context = createFixture();
try {
const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db);
const moved = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) });
assert.equal(row(context.db, "row-1").quantity, 1);
assert.equal(row(context.db, "row-2").quantity, 2);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2");
const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: moved.revision.changeSetId, command: moved.inverse });
assert.equal(row(context.db, "row-1").quantity, 3);
assert.equal(row(context.db, "row-2").quantity, 0);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: moved.revision.changeSetId, command: undone.inverse });
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2");
} finally {
context.close();
}
});
it("detaches the last object but keeps the now-manual row", () => {
const context = createFixture();
try {
const currentRow = row(context.db, "row-1");
const object = objectSnapshot("row-1");
const command = createExternalObjectRowAssignmentProjectCommand({
rows: [{ expected: currentRow, target: { ...currentRow, quantity: 1 } }],
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: null } }],
confirmedConflictObjectIds: [],
});
new ExternalObjectRowAssignmentProjectCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(row(context.db, "row-1").quantity, 1);
assert.equal(row(context.db, "row-1").manualQuantity, 1);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
} finally {
context.close();
}
});
it("requires explicit confirmation for differing planning values", () => {
const context = createFixture();
try {
context.db.update(circuitDeviceRows).set({ displayName: "Abweichend" }).where(eq(circuitDeviceRows.id, "row-2")).run();
const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db);
assert.throws(
() => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }),
/explicit conflict confirmation/
);
repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db, ["object-1"]) });
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2");
} finally {
context.close();
}
});
it("rolls back row quantities and links when history persistence fails", () => {
const context = createFixture();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_assignment_history
BEFORE INSERT ON project_change_sets
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
`);
const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db);
assert.throws(
() => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }),
/late history failure/
);
assert.equal(row(context.db, "row-1").quantity, 3);
assert.equal(row(context.db, "row-2").quantity, 0);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
} finally {
context.close();
}
});
});
describe("external object new-row project command", () => {
it("creates an external-only row and supports exact persisted undo and redo", () => {
const context = createFixture(null);
try {
const base = row(context.db, "row-2");
const newRow = {
...base,
id: "row-new",
quantity: 2,
manualQuantity: 0,
sortOrder: 20,
};
const object = objectSnapshot(null);
const command = createExternalObjectNewRowProjectCommand(
externalObjectAssignToNewRowCommandType,
{
row: newRow,
objects: [{
expected: object,
target: { ...object, circuitDeviceRowId: "row-new" },
}],
confirmedConflictObjectIds: [],
}
);
const repository = new ExternalObjectNewRowProjectCommandRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(row(context.db, "row-new").manualQuantity, 0);
assert.equal(row(context.db, "row-new").quantity, 2);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new");
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: inserted.inverse,
}),
/only be removed through project history/
);
const undone = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(),
undefined
);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: undone.inverse,
});
assert.equal(row(context.db, "row-new").quantity, 2);
} finally {
context.close();
}
});
it("rolls back the row and object link when history persistence fails", () => {
const context = createFixture(null);
try {
context.sqlite.exec(`
CREATE TRIGGER fail_new_row_history
BEFORE INSERT ON project_change_sets
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
`);
const base = row(context.db, "row-2");
const object = objectSnapshot(null);
const command = createExternalObjectNewRowProjectCommand(
externalObjectAssignToNewRowCommandType,
{
row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 },
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }],
confirmedConflictObjectIds: [],
}
);
assert.throws(
() => new ExternalObjectNewRowProjectCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
}),
/late history failure/
);
assert.equal(
context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(),
undefined
);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
} finally {
context.close();
}
});
it("keeps the created row when it changed after assignment", () => {
const context = createFixture(null);
try {
const base = row(context.db, "row-2");
const object = objectSnapshot(null);
const repository = new ExternalObjectNewRowProjectCommandRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalObjectNewRowProjectCommand(
externalObjectAssignToNewRowCommandType,
{
row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 },
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }],
confirmedConflictObjectIds: [],
}
),
});
context.db.update(circuitDeviceRows).set({ displayName: "Geändert" })
.where(eq(circuitDeviceRows.id, "row-new")).run();
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
}),
/changed before history removal/
);
assert.equal(row(context.db, "row-new").displayName, "Geändert");
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new");
} finally {
context.close();
}
});
});
function newExternalCircuit(database: AppDatabase) {
const sourceRow = row(database, "row-2");
return {
id: "circuit-new",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-2F1.3",
displayName: "Steckdose",
sortOrder: 30,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: false,
remark: null,
deviceRows: [{
...sourceRow,
id: "row-new",
circuitId: "circuit-new",
quantity: 2,
manualQuantity: 0,
}],
protectionDevice: {
circuitId: "circuit-new",
type: "FI_LS" as const,
ratedCurrentA: 16,
fuseUtilizationCategory: null,
tripCharacteristic: "B" as const,
rcdType: "A" as const,
ratedResidualCurrentMa: 30,
},
};
}
describe("external object new-circuit project command", () => {
it("creates circuit, protection, row and links as one undoable revision", () => {
const context = createFixture(null);
try {
const object = objectSnapshot(null);
const circuit = newExternalCircuit(context.db);
const command = createExternalObjectNewCircuitProjectCommand(
externalObjectAssignToNewCircuitCommandType,
{
circuit,
objects: [{
expected: object,
target: { ...object, circuitDeviceRowId: "row-new" },
}],
confirmedConflictObjectIds: [],
}
);
const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()!
.equipmentIdentifier,
"-2F1.3"
);
assert.equal(row(context.db, "row-new").manualQuantity, 0);
assert.equal(
context.db.select().from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new")).get()!.type,
"FI_LS"
);
assert.equal(
context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId,
"row-new"
);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: inserted.inverse,
}),
/only be removed through project history/
);
const undone = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(),
undefined
);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: undone.inverse,
});
assert.equal(
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()!
.equipmentIdentifier,
"-2F1.3"
);
} finally {
context.close();
}
});
it("rejects a preplanned BMK that does not belong to the target group", () => {
const context = createFixture(null);
try {
const object = objectSnapshot(null);
const circuit = {
...newExternalCircuit(context.db),
equipmentIdentifier: "-1F1.3",
};
assert.throws(
() => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalObjectNewCircuitProjectCommand(
externalObjectAssignToNewCircuitCommandType,
{
circuit,
objects: [{
expected: object,
target: { ...object, circuitDeviceRowId: "row-new" },
}],
confirmedConflictObjectIds: [],
}
),
}),
/equipment identifier does not match target group/
);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
} finally {
context.close();
}
});
it("rolls back the complete circuit subtree when history persistence fails", () => {
const context = createFixture(null);
try {
context.sqlite.exec(`
CREATE TRIGGER fail_new_circuit_history
BEFORE INSERT ON project_change_sets
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
`);
const object = objectSnapshot(null);
assert.throws(
() => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalObjectNewCircuitProjectCommand(
externalObjectAssignToNewCircuitCommandType,
{
circuit: newExternalCircuit(context.db),
objects: [{
expected: object,
target: { ...object, circuitDeviceRowId: "row-new" },
}],
confirmedConflictObjectIds: [],
}
),
}),
/late history failure/
);
assert.equal(
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(),
undefined
);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
} finally {
context.close();
}
});
});