leistungsbilanz-ts/tests/external-xlsx-follow-up-import.test.ts
Julian Appel 7222398628 Typecheck tests against current types
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>
2026-10-29 13:17:45 +01:00

80 lines
8.9 KiB
TypeScript

import assert from "node:assert/strict";
import { describe, it } from "node:test";
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 { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, loadXlsxWorkbook, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
const newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003";
const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] };
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, 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 }] };
async function initialState() {
const bytes = await createRevitXlsxFixture();
const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes, configuration, catalogs });
let sequence = 0;
const state = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
const missing = structuredClone(state.objects[1]!); missing.id = "30000000-0000-4000-8000-999999999999"; missing.worksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000099";
state.objects.push(missing);
return state;
}
async function changedWorkbook(firstId = firstWorksharingId) {
const workbook = await loadXlsxWorkbook(await createRevitXlsxFixture());
const sheet = workbook.worksheets[0]!;
sheet.getCell(3, 1).value = firstId; sheet.getCell(3, 6).value = 3;
sheet.getCell(5, 1).value = newWorksharingId; sheet.getCell(5, 2).value = "Leuchte neu"; sheet.getCell(5, 5).value = "Leuchte: Decke"; sheet.getCell(5, 6).value = 1;
return new Uint8Array(await workbook.xlsx.writeBuffer());
}
function currentReturns(state: Awaited<ReturnType<typeof initialState>>) {
const map = new Map<string, ExternalXlsxReturnValues>();
for (const object of state.objects) map.set(object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA });
map.set(state.objects[0]!.id, { ...map.get(state.objects[0]!.id)!, designation: "Noch nicht in Revit" });
return map;
}
describe("Linear XLSX follow-up import", () => {
it("classifies new, changed, unchanged, missing and return-deviating objects with exact IDs", async () => {
const state = await initialState();
const bytes = await changedWorkbook();
const { plan } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
assert.deepEqual({ new: plan.newObjectCount, changed: plan.changedObjectCount, unchanged: plan.unchangedObjectCount, missing: plan.missingObjectCount, returnDeviation: plan.returnDeviationCount }, { new: 1, changed: 1, unchanged: 1, missing: 1, returnDeviation: 1 });
assert.deepEqual(plan.changes[0]!.changedFields, ["socketCount"]);
assert.equal(plan.missingObjects[0]!.worksharingId.endsWith("99"), true);
const caseChanged = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes: await changedWorkbook(firstWorksharingId.toLowerCase()), configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
assert.equal(caseChanged.plan.newObjectCount, 2);
assert.equal(caseChanged.plan.missingObjectCount, 2);
});
it("updates accepted quantities and row totals while preserving missing links and compacting old batches", async () => {
const state = await initialState();
const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!;
first.circuitDeviceRowId = "row-1";
const bytes = await changedWorkbook();
const { plan, document } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
let sequence = 0;
const row = { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", connectionKind: null, costGroup: null, category: "single_phase", level: null, roomId: null, roomNumberSnapshot: null, roomNameSnapshot: null, quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9, remark: null, overriddenFields: null };
const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
assert.equal(built.rowTransitions[0]!.target.quantity, 4);
assert.equal(built.targetState.objects.find((object) => object.id === first.id)!.planningValues.effectiveQuantity, 3);
const missing = built.targetState.objects.find((object) => object.worksharingId.endsWith("99"))!;
assert.equal(missing.presenceStatus, "missing");
assert.equal(missing.circuitDeviceRowId, null);
assert.equal(built.targetState.importBatches[0]!.originalContentBase64, null);
assert.ok(built.targetState.importBatches[1]!.originalContentBase64);
const rejected = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: false })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `50000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
const rejectedObject = rejected.targetState.objects.find((object) => object.id === first.id)!;
assert.equal(rejectedObject.planningValues.effectiveQuantity, first.planningValues.effectiveQuantity);
assert.equal(rejectedObject.lastAcceptedImportBatchId, first.lastAcceptedImportBatchId);
assert.notEqual(rejectedObject.lastSeenImportBatchId, first.lastSeenImportBatchId);
assert.equal(rejected.rowTransitions.length, 0);
});
});