leistungsbilanz-ts/tests/revit-xlsx-end-to-end.test.ts

143 lines
16 KiB
TypeScript

import assert from "node:assert/strict";
import { existsSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.js";
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
import { ExternalObjectPlanningProjectCommandRepository } from "../src/db/repositories/external-object-planning-project-command.repository.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.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 { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
import { projects } from "../src/db/schema/projects.js";
import { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js";
import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js";
import { createExternalObjectPlanningUpdateProjectCommand } from "../src/domain/models/external-object-planning-project-command.model.js";
import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js";
import { assertProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js";
import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js";
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { createExternalXlsxReturn } from "../src/external-model/xlsx/external-xlsx-transport.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
const projectId = "project-e2e";
const boardId = "board-e2e";
const listId = "list-e2e";
const sectionId = "section-e2e";
const circuitId = "circuit-e2e";
const rowId = "row-e2e";
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: 125, simultaneityFactor: 1, cosPhi: 0.9, costGroup: "440" } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
describe("Linear XLSX end-to-end", () => {
it("survives return, follow-up, snapshot restore, duplicate and restart undo/redo", async () => {
const databasePath = path.join(tmpdir(), `leistungsbilanz-r10-${randomUUID()}.sqlite`);
let context = createDatabaseContext(databasePath);
try {
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
seedProject(context.db);
const configurationSnapshot = { id: "config-e2e", projectId, configuration };
context.db.insert(externalModelConfigurations).values(configurationSnapshot).run();
const initialBytes = await createRevitXlsxFixture();
const initial = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes: initialBytes, configuration, catalogs: emptyCatalogs() });
let sequence = 0;
const initialState = buildExternalXlsxInitialImportTarget({ projectId, expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(initialBytes).toString("base64"), document: initial.document, plan: initial.plan, roomDecisions: initial.plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: boardId })), familyProjectDeviceDecisions: initial.plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: boardId, createId: () => nextId("3", ++sequence) });
new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), initialState) });
let state = new ExternalModelStateRepository(context.db).getByProject(projectId).state;
const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!;
const plannedFirst = { ...first, planningValues: { ...first.planningValues, remark: "Geprüft" }, overriddenFields: [...first.overriddenFields, "remark" as const] };
new ExternalObjectPlanningProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 1, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: first, target: plannedFirst }]) });
const rowBeforeAssignment = currentRow(context.db);
const assignedFirst = { ...plannedFirst, distributionBoardId: boardId, circuitDeviceRowId: rowId };
new ExternalObjectRowAssignmentProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 2, source: "user", command: createExternalObjectRowAssignmentProjectCommand({ rows: [{ expected: rowBeforeAssignment, target: { ...rowBeforeAssignment, quantity: 3 } }], objects: [{ expected: plannedFirst, target: assignedFirst }], confirmedConflictObjectIds: [first.id] }) });
state = new ExternalModelStateRepository(context.db).getByProject(projectId).state;
const projected = state.objects.map((object) => projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? { rowId, displayName: "Steckdose", powerPerUnitKw: 0.125, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } : null, circuitMode: configuration.circuitMode, singlePhaseVoltageV: 230, threePhaseVoltageV: 400 }));
const latestBatch = state.importBatches.at(-1)!;
const returned = await createExternalXlsxReturn({ fileName: latestBatch.fileName, bytes: Buffer.from(latestBatch.originalContentBase64!, "base64"), configuration, selectedWorksharingIds: projected.map((entry) => entry.worksharingId), updates: projected.map((entry) => ({ worksharingId: entry.worksharingId, values: entry.values })) });
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(returned.bytes); workbook.worksheets[0]!.getCell(3, 6).value = 3;
const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer());
const returnValues = new Map(projected.map((entry) => [entry.objectId, entry.values]));
const follow = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs: emptyCatalogs(), currentState: state, currentReturnValuesByObjectId: returnValues });
const built = buildExternalXlsxFollowUpTarget({ projectId, expectedRevision: 3, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: follow.document, plan: follow.plan, currentState: state, sourceChangeDecisions: follow.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: follow.plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: boardId })), familyProjectDeviceDecisions: follow.plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: boardId, rowsById: new Map([[rowId, currentRow(context.db)]]), createId: () => nextId("4", ++sequence) });
new ExternalFollowUpImportProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 3, source: "user", command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: state, targetState: built.targetState, rows: built.rowTransitions }) });
assert.equal(currentRow(context.db).quantity, 4);
state = new ExternalModelStateRepository(context.db).getByProject(projectId).state;
assert.equal(state.importBatches.length, 2);
assert.equal(state.importBatches[0]!.originalContentBase64, null);
const snapshot = new ProjectSnapshotRepository(context.db).createNamed({ projectId, expectedRevision: 4, name: "Nach Folgeimport" });
assert.ok(snapshot);
const second = state.objects.find((object) => object.worksharingId === secondWorksharingId)!;
const changedSecond = { ...second, planningValues: { ...second.planningValues, simultaneityFactor: 0.55 }, overriddenFields: [...second.overriddenFields, "simultaneityFactor" as const] };
new ExternalObjectPlanningProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 4, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: second, target: changedSecond }]) });
const prepared = new ProjectSnapshotRepository(context.db).prepareRestore(projectId, snapshot.id);
assert.ok(prepared);
new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 5, source: "restore", command: prepared.command });
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject(projectId);
assert.ok(transfer);
const duplicate = transferRepository.importDuplicate(transfer);
const duplicateState = readProjectStateSnapshot(context.db, duplicate.projectId)!.state;
const restoredState = readProjectStateSnapshot(context.db, projectId)!.state;
assert.deepEqual(duplicateState.externalModel.objects.map((object) => object.worksharingId).sort(), restoredState.externalModel.objects.map((object) => object.worksharingId).sort());
assert.notEqual(duplicateState.externalModel.objects[0]!.id, restoredState.externalModel.objects[0]!.id);
assert.equal(duplicateState.externalModel.importBatches.at(-1)!.configurationSnapshot!.deviceTypes[0]!.id, duplicateState.externalModelConfiguration!.configuration.deviceTypes[0]!.id);
context.close();
context = createDatabaseContext(databasePath);
const history = new ProjectHistoryRepository(context.db);
const undo = history.getNextCommand(projectId, "undo");
assert.ok(undo); assertProjectStateRestoreCommand(undo.command);
new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 6, source: "undo", historyTargetChangeSetId: undo.changeSetId, command: undo.command });
assert.equal(new ExternalModelStateRepository(context.db).getByProject(projectId).state.objects.find((object) => object.worksharingId === secondWorksharingId)!.planningValues.simultaneityFactor, 0.55);
const redo = history.getNextCommand(projectId, "redo");
assert.ok(redo); assertProjectStateRestoreCommand(redo.command);
new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 7, source: "redo", historyTargetChangeSetId: redo.changeSetId, command: redo.command });
assert.equal(new ExternalModelStateRepository(context.db).getByProject(projectId).state.objects.find((object) => object.worksharingId === secondWorksharingId)!.planningValues.simultaneityFactor, 0.8);
assert.equal(currentRow(context.db).quantity, 4);
} finally {
context.close();
for (const file of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]) if (existsSync(file)) unlinkSync(file);
}
});
});
function seedProject(database: ReturnType<typeof createDatabaseContext>["db"]) {
database.insert(projects).values({ id: projectId, name: "R10 Projekt" }).run();
database.insert(distributionBoards).values({ id: boardId, projectId, name: "UV 1" }).run();
database.insert(circuitLists).values({ id: listId, projectId, distributionBoardId: boardId, name: "Liste" }).run();
database.insert(circuitSections).values({ id: sectionId, circuitListId: listId, key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run();
database.insert(circuits).values({ id: circuitId, circuitListId: listId, sectionId, equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run();
database.insert(circuitProtectionDevices).values({ circuitId, type: "LS", ratedCurrentA: 16, fuseUtilizationCategory: null, tripCharacteristic: "B", rcdType: null, ratedResidualCurrentMa: null }).run();
database.insert(circuitDeviceRows).values({ id: rowId, circuitId, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 1, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run();
}
function currentRow(database: ReturnType<typeof createDatabaseContext>["db"]) { return toCircuitDeviceRowSnapshot(database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).get()!); }
function emptyCatalogs() { return { floors: [], rooms: [], distributionBoards: [{ id: boardId, name: "UV 1" }], projectDevices: [] }; }
function nextId(prefix: string, sequence: number) { return `${prefix}0000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; }