Add external object row assignment command

This commit is contained in:
2026-08-02 18:38:38 +02:00
parent dac19b093c
commit 1a7dd7fe03
10 changed files with 693 additions and 12 deletions
@@ -0,0 +1,227 @@
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 { 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 { 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 type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.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();
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 ? 3 : 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("row-1")).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();
}
});
});