leistungsbilanz-ts/tests/external-object-assignment-plan-project-command.repository.test.ts
Julian Appel fd45a1caeb Add atomic Revit assignment-plan command and route
- external-object.apply-assignment-plan creates planned circuits (BMK,
  default protection), external-only rows and quantity changes with all
  object links in one revision; the history-only revert removes only
  unchanged created structure.
- POST /external-model/objects/assignment-plan validates strictly with
  zod, re-checks rows with the shared planning rules and requires explicit
  confirmation of planning conflicts and socket-limit overruns.
- The compact object list carries the socket limit and socket device types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:28:56 +01:00

262 lines
14 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 { ExternalObjectAssignmentPlanProjectCommandRepository } from "../src/db/repositories/external-object-assignment-plan-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 { 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 { 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 type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import type { CircuitSnapshot } from "../src/domain/models/circuit-structure-project-command.model.js";
import {
createExternalObjectAssignmentPlanProjectCommand,
externalObjectApplyAssignmentPlanCommandType,
externalObjectRevertAssignmentPlanCommandType,
} from "../src/domain/models/external-object-assignment-plan-project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
function createFixture() {
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();
for (const [id, number] of [["room-1", "101"], ["room-2", "102"]] as const) {
database.insert(rooms).values({ id, projectId: "project-1", floorId: "floor-1", roomNumber: number, 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();
database.insert(circuits).values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", displayName: "Steckdose", sortOrder: 10, voltage: 230 }).run();
database.insert(circuits).values({ id: "circuit-2", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.2", displayName: "Reserve", sortOrder: 20, voltage: 230, isReserve: 1 }).run();
database.insert(circuitDeviceRows).values(rowValues({ id: "row-1", circuitId: "circuit-1", quantity: 2 })).run();
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_xlsx" }).run();
database.insert(externalImportBatches).values({
id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial",
importedAtIso: "2026-10-30T10:00:00.000Z", fileName: "revit.xlsx", sha256: "a".repeat(64), objectCount: 4,
}).run();
for (const [id, roomId, number] of [["mapping-1", "room-1", "101"], ["mapping-2", "room-2", "102"]] as const) {
database.insert(externalRoomMappings).values({
id, projectId: "project-1", sourceId: "source-1", normalizedSourceRoomKey: `number:${number}`,
sourceFloorName: "EG", sourceRoomNumber: number, sourceRoomName: "Büro", roomId, defaultDistributionBoardId: "board-1",
}).run();
}
database.insert(externalModelObjects).values([
objectSnapshot("object-0", "mapping-1", "row-1"),
objectSnapshot("object-a1", "mapping-1", null),
objectSnapshot("object-a2", "mapping-1", null),
objectSnapshot("object-b1", "mapping-2", null),
]).run();
return context;
}
function rowValues(overrides: Partial<CircuitDeviceRowSnapshot> & { id: string; circuitId: string }): CircuitDeviceRowSnapshot {
return {
linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose: Doppel", displayName: "Steckdose",
phaseType: "single_phase", connectionKind: "socket", costGroup: null, category: "single_phase", level: null,
roomId: "room-1", roomNumberSnapshot: "101", roomNameSnapshot: "Büro", quantity: 2, manualQuantity: 0,
powerPerUnit: 0.12, simultaneityFactor: 1, cosPhi: null, remark: null, overriddenFields: null,
...overrides,
};
}
const worksharingIds: Record<string, string> = {
"object-0": "00000000-0000-4000-8000-000000000000-00000001",
"object-a1": "00000000-0000-4000-8000-000000000001-00000001",
"object-a2": "00000000-0000-4000-8000-000000000002-00000001",
"object-b1": "00000000-0000-4000-8000-000000000003-00000001",
};
function objectSnapshot(id: string, mappingId: string, circuitDeviceRowId: string | null): ExternalModelObjectSnapshot {
const roomNumber = mappingId === "mapping-1" ? "101" : "102";
return {
id, projectId: "project-1", sourceId: "source-1", worksharingId: worksharingIds[id]!, ifcGuid: null,
deviceTypeId: "device-type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
rowNumber: 3, roomNumber, roomName: "Büro", familyAndType: "Steckdose: Doppel", selectionMarker: "SD",
socketCount: "2", sourceRemark: null, circuitCombined: null, boardName: null, circuitIdentifier: null,
designation: null, electricalPowerW: null, voltageV: null, currentA: null,
},
planningValues: {
displayName: "Steckdose", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2,
powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null,
},
overriddenFields: [], externalRoomMappingId: mappingId, distributionBoardId: "board-1",
linkedProjectDeviceId: null, circuitDeviceRowId, presenceStatus: "present",
};
}
function link(id: string, mappingId: string, rowId: string) {
const expected = objectSnapshot(id, mappingId, null);
return { expected, target: { ...expected, circuitDeviceRowId: rowId } };
}
function planCommand(database: AppDatabase) {
const existing = toCircuitDeviceRowSnapshot(
database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!
);
const newCircuit: CircuitSnapshot = {
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: [rowValues({ id: "row-new-b", circuitId: "circuit-new", roomId: "room-2", roomNumberSnapshot: "102" })],
protectionDevice: {
circuitId: "circuit-new", type: "FI_LS", ratedCurrentA: 16, fuseUtilizationCategory: null,
tripCharacteristic: "B", rcdType: "A", ratedResidualCurrentMa: 30,
},
};
return createExternalObjectAssignmentPlanProjectCommand(externalObjectApplyAssignmentPlanCommandType, {
newCircuits: [newCircuit],
newRows: [rowValues({ id: "row-new-a", circuitId: "circuit-2" })],
rows: [{ expected: existing, target: { ...existing, quantity: 4 } }],
objects: [
link("object-a1", "mapping-1", "row-1"),
link("object-a2", "mapping-1", "row-new-a"),
link("object-b1", "mapping-2", "row-new-b"),
],
confirmedConflictObjectIds: [],
});
}
function readState(database: AppDatabase) {
const circuitRows = database.select().from(circuits).all()
.map(({ id, displayName, isReserve, equipmentIdentifier }) => ({ id, displayName, isReserve, equipmentIdentifier }))
.sort((left, right) => left.id.localeCompare(right.id));
const rows = database.select().from(circuitDeviceRows).all()
.map(({ id, circuitId, quantity }) => ({ id, circuitId, quantity }))
.sort((left, right) => left.id.localeCompare(right.id));
const links = Object.fromEntries(
database.select().from(externalModelObjects).all().map((object) => [object.id, object.circuitDeviceRowId])
);
const protections = database.select().from(circuitProtectionDevices).all().map((entry) => entry.circuitId);
const revision = database.select().from(projects).get()!.currentRevision;
return { circuitRows, rows, links, protections, revision };
}
describe("external object assignment-plan project command", () => {
it("applies a plan atomically and restores it exactly through undo and redo", () => {
const context = createFixture();
try {
const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db);
const before = readState(context.db);
const applied = repository.execute({
projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db),
});
const after = readState(context.db);
assert.deepEqual(after.links, {
"object-0": "row-1", "object-a1": "row-1", "object-a2": "row-new-a", "object-b1": "row-new-b",
});
assert.deepEqual(after.rows.find((entry) => entry.id === "row-1"), { id: "row-1", circuitId: "circuit-1", quantity: 4 });
assert.deepEqual(after.circuitRows.find((entry) => entry.id === "circuit-2"), {
id: "circuit-2", displayName: "Steckdose", isReserve: 0, equipmentIdentifier: "-2F1.2",
});
assert.deepEqual(after.circuitRows.find((entry) => entry.id === "circuit-new")?.equipmentIdentifier, "-2F1.3");
assert.deepEqual(after.protections, ["circuit-new"]);
assert.equal(applied.inverse.type, externalObjectRevertAssignmentPlanCommandType);
assert.deepEqual(applied.inverse.payload.circuitDisplayNames, [
{ circuitId: "circuit-2", expected: "Steckdose", target: "Reserve" },
]);
const undone = repository.execute({
projectId: "project-1", expectedRevision: 1, source: "undo",
historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse,
});
assert.deepEqual({ ...readState(context.db), revision: 0 }, { ...before, revision: 0 });
repository.execute({
projectId: "project-1", expectedRevision: 2, source: "redo",
historyTargetChangeSetId: applied.revision.changeSetId, command: undone.inverse,
});
assert.deepEqual({ ...readState(context.db), revision: 0 }, { ...after, revision: 0 });
} finally {
context.close();
}
});
it("rolls back completely when an object changed and rejects a user revert", () => {
const context = createFixture();
try {
const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db);
const command = planCommand(context.db);
context.db.update(externalModelObjects)
.set({ circuitDeviceRowId: "row-1" })
.where(eq(externalModelObjects.id, "object-b1")).run();
const before = readState(context.db);
assert.throws(
() => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command }),
/changed before plan assignment/
);
assert.deepEqual(readState(context.db), before);
const revert = createExternalObjectAssignmentPlanProjectCommand(
externalObjectRevertAssignmentPlanCommandType,
{ ...command.payload, objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })) }
);
assert.throws(
() => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: revert }),
/only be reverted through project history/
);
} finally {
context.close();
}
});
it("refuses undo after a created row was changed", () => {
const context = createFixture();
try {
const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db);
const applied = repository.execute({
projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db),
});
context.db.update(circuitDeviceRows).set({ remark: "geändert" })
.where(eq(circuitDeviceRows.id, "row-new-a")).run();
const before = readState(context.db);
assert.throws(
() => repository.execute({
projectId: "project-1", expectedRevision: 1, source: "undo",
historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse,
}),
/changed before history removal/
);
assert.deepEqual(readState(context.db), before);
} finally {
context.close();
}
});
it("requires explicit confirmation of planning-value conflicts on existing rows", () => {
const context = createFixture();
try {
context.db.update(circuitDeviceRows).set({ simultaneityFactor: 0.5 })
.where(eq(circuitDeviceRows.id, "row-1")).run();
const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db);
assert.throws(
() => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db) }),
/explicit conflict confirmation/
);
const confirmed = createExternalObjectAssignmentPlanProjectCommand(
externalObjectApplyAssignmentPlanCommandType,
{ ...planCommand(context.db).payload, confirmedConflictObjectIds: ["object-a1"] }
);
assert.doesNotThrow(() =>
repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: confirmed })
);
} finally {
context.close();
}
});
});