tsx runs tests without type checking and tsconfig.json covers only src, so 71 type errors had accumulated unnoticed. tsconfig.tests.json now checks every test and helper, and npm run typecheck includes it. Fixtures gain the fields the DTOs have grown since they were written (manualQuantity, circuit apparent power/cos phi, downstream board loads, section totals, building, cover and board info fields). Repository lookups that return "row ?? null" now declare the null in their type, which their callers already handle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
16 KiB
TypeScript
142 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 { 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, loadXlsxWorkbook, 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 = await loadXlsxWorkbook(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", floorId: null }], projectDevices: [] }; }
|
|
function nextId(prefix: string, sequence: number) { return `${prefix}0000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; }
|