Editing "Anzahl" on a row with linked Revit objects set manualQuantity to the new total, so the row no longer matched its objects and detaching them failed with "row.manualQuantity must not exceed row.quantity". Such rows now lock "Anzahl" in the editor. The new optional columns "Anz. manuell" (editable) and "Anz. Revit" (read-only) show both shares; stored column layouts get them hidden next to "Anzahl". The device-row update derives the total from a manual-only edit and rejects any change that breaks quantity = manual + Revit, for every source. Two history fixtures inserted rows with quantity 1 and the column default manualQuantity 0, a state no runtime path creates; they now insert 1/1. Undoing "Revit-Objekte lösen" re-validated planning-value conflicts that the user had confirmed on the original assignment and was rejected. Undo/redo of row assignments now restores the accepted state without a new confirmation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
775 lines
31 KiB
TypeScript
775 lines
31 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 { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
|
||
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
|
||
import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js";
|
||
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
|
||
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
|
||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-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 { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.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 { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||
import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js";
|
||
import {
|
||
createExternalObjectNewRowProjectCommand,
|
||
externalObjectAssignToNewRowCommandType,
|
||
} from "../src/domain/models/external-object-new-row-project-command.model.js";
|
||
import {
|
||
createExternalObjectNewCircuitProjectCommand,
|
||
externalObjectAssignToNewCircuitCommandType,
|
||
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
|
||
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
||
import { createCircuitDeviceRowDeleteProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
||
import { createCircuitDeleteProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
|
||
import { assertDistributionBoardHasNoExternalObjects, assertRoomHasNoExternalObjects } from "../src/db/repositories/external-object-deletion-guard.persistence.js";
|
||
|
||
function createFixture(initialObjectRowId: string | null = "row-1") {
|
||
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 ? (initialObjectRowId === "row-1" ? 3 : 1) : 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_xlsx" }).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.xlsx",
|
||
sha256: "a".repeat(64),
|
||
objectCount: 1,
|
||
}).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(initialObjectRowId)).run();
|
||
return context;
|
||
}
|
||
|
||
function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectSnapshot {
|
||
return {
|
||
id: "object-1",
|
||
projectId: "project-1",
|
||
sourceId: "source-1",
|
||
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
|
||
ifcGuid: "ifc-1",
|
||
deviceTypeId: "device-type-1",
|
||
lastSeenImportBatchId: "batch-1",
|
||
lastAcceptedImportBatchId: "batch-1",
|
||
acceptedSourceValues: {
|
||
rowNumber: 2,
|
||
roomNumber: "101",
|
||
roomName: "Büro",
|
||
familyAndType: "Steckdose: Standard",
|
||
selectionMarker: "Steckdose",
|
||
socketCount: "2",
|
||
sourceRemark: null,
|
||
circuitCombined: "-2F1.1",
|
||
boardName: "UV 1",
|
||
circuitIdentifier: "-2F1.1",
|
||
designation: null,
|
||
electricalPowerW: "120",
|
||
voltageV: 230,
|
||
currentA: null,
|
||
},
|
||
planningValues: {
|
||
displayName: "Steckdose",
|
||
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("re-links a detached object on undo although its planning values differ from the row", () => {
|
||
const context = createFixture();
|
||
try {
|
||
// The original assignment confirmed this planning-value conflict.
|
||
context.db.update(circuitDeviceRows).set({ displayName: "Abweichend" }).where(eq(circuitDeviceRows.id, "row-1")).run();
|
||
const currentRow = row(context.db, "row-1");
|
||
const object = objectSnapshot("row-1");
|
||
const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db);
|
||
const detached = repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command: createExternalObjectRowAssignmentProjectCommand({
|
||
rows: [{ expected: currentRow, target: { ...currentRow, quantity: 1 } }],
|
||
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: null } }],
|
||
confirmedConflictObjectIds: [],
|
||
}),
|
||
});
|
||
|
||
const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: detached.revision.changeSetId, command: detached.inverse });
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
|
||
assert.deepEqual([row(context.db, "row-1").quantity, row(context.db, "row-1").manualQuantity], [3, 1]);
|
||
|
||
repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: detached.revision.changeSetId, command: undone.inverse });
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||
} 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();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("device-row quantity edits with linked Revit objects", () => {
|
||
const quantities = (database: AppDatabase, id: string) => {
|
||
const current = row(database, id);
|
||
return [current.quantity, current.manualQuantity];
|
||
};
|
||
|
||
it("rejects a total edit on a linked row and keeps Revit and manual share consistent", () => {
|
||
const context = createFixture();
|
||
try {
|
||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||
assert.throws(
|
||
() => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 4 }) }),
|
||
/mit Revit-Objekten verknüpft/
|
||
);
|
||
assert.deepEqual(quantities(context.db, "row-1"), [3, 1]);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("derives the total from a manual-share edit through undo and redo", () => {
|
||
const context = createFixture();
|
||
try {
|
||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||
const edited = store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { manualQuantity: 2 }) });
|
||
assert.deepEqual(quantities(context.db, "row-1"), [4, 2]);
|
||
|
||
const undone = store.executeUpdate({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: edited.revision.changeSetId, command: edited.inverse });
|
||
assert.deepEqual(quantities(context.db, "row-1"), [3, 1]);
|
||
|
||
store.executeUpdate({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: edited.revision.changeSetId, command: undone.inverse });
|
||
assert.deepEqual(quantities(context.db, "row-1"), [4, 2]);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("keeps editing the total of a row without Revit objects", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
new CircuitDeviceRowProjectCommandRepository(context.db).executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 5 }) });
|
||
assert.deepEqual(quantities(context.db, "row-1"), [5, 5]);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("external object new-row project command", () => {
|
||
it("creates an external-only row and supports exact persisted undo and redo", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
const base = row(context.db, "row-2");
|
||
const newRow = {
|
||
...base,
|
||
id: "row-new",
|
||
quantity: 2,
|
||
manualQuantity: 0,
|
||
sortOrder: 20,
|
||
};
|
||
const object = objectSnapshot(null);
|
||
const command = createExternalObjectNewRowProjectCommand(
|
||
externalObjectAssignToNewRowCommandType,
|
||
{
|
||
row: newRow,
|
||
objects: [{
|
||
expected: object,
|
||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||
}],
|
||
confirmedConflictObjectIds: [],
|
||
}
|
||
);
|
||
const repository = new ExternalObjectNewRowProjectCommandRepository(context.db);
|
||
const inserted = repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command,
|
||
});
|
||
assert.equal(row(context.db, "row-new").manualQuantity, 0);
|
||
assert.equal(row(context.db, "row-new").quantity, 2);
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new");
|
||
assert.throws(
|
||
() => repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 1,
|
||
source: "user",
|
||
command: inserted.inverse,
|
||
}),
|
||
/only be removed through project history/
|
||
);
|
||
|
||
const undone = repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 1,
|
||
source: "undo",
|
||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||
command: inserted.inverse,
|
||
});
|
||
assert.equal(
|
||
context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(),
|
||
undefined
|
||
);
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||
|
||
repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 2,
|
||
source: "redo",
|
||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||
command: undone.inverse,
|
||
});
|
||
assert.equal(row(context.db, "row-new").quantity, 2);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("rolls back the row and object link when history persistence fails", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
context.sqlite.exec(`
|
||
CREATE TRIGGER fail_new_row_history
|
||
BEFORE INSERT ON project_change_sets
|
||
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
|
||
`);
|
||
const base = row(context.db, "row-2");
|
||
const object = objectSnapshot(null);
|
||
const command = createExternalObjectNewRowProjectCommand(
|
||
externalObjectAssignToNewRowCommandType,
|
||
{
|
||
row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 },
|
||
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }],
|
||
confirmedConflictObjectIds: [],
|
||
}
|
||
);
|
||
assert.throws(
|
||
() => new ExternalObjectNewRowProjectCommandRepository(context.db).execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command,
|
||
}),
|
||
/late history failure/
|
||
);
|
||
assert.equal(
|
||
context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(),
|
||
undefined
|
||
);
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("keeps the created row when it changed after assignment", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
const base = row(context.db, "row-2");
|
||
const object = objectSnapshot(null);
|
||
const repository = new ExternalObjectNewRowProjectCommandRepository(context.db);
|
||
const inserted = repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command: createExternalObjectNewRowProjectCommand(
|
||
externalObjectAssignToNewRowCommandType,
|
||
{
|
||
row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 },
|
||
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }],
|
||
confirmedConflictObjectIds: [],
|
||
}
|
||
),
|
||
});
|
||
context.db.update(circuitDeviceRows).set({ displayName: "Geändert" })
|
||
.where(eq(circuitDeviceRows.id, "row-new")).run();
|
||
assert.throws(
|
||
() => repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 1,
|
||
source: "undo",
|
||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||
command: inserted.inverse,
|
||
}),
|
||
/changed before history removal/
|
||
);
|
||
assert.equal(row(context.db, "row-new").displayName, "Geändert");
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new");
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
});
|
||
|
||
function newExternalCircuit(database: AppDatabase) {
|
||
const sourceRow = row(database, "row-2");
|
||
return {
|
||
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: [{
|
||
...sourceRow,
|
||
id: "row-new",
|
||
circuitId: "circuit-new",
|
||
quantity: 2,
|
||
manualQuantity: 0,
|
||
}],
|
||
protectionDevice: {
|
||
circuitId: "circuit-new",
|
||
type: "FI_LS" as const,
|
||
ratedCurrentA: 16,
|
||
fuseUtilizationCategory: null,
|
||
tripCharacteristic: "B" as const,
|
||
rcdType: "A" as const,
|
||
ratedResidualCurrentMa: 30,
|
||
},
|
||
};
|
||
}
|
||
|
||
describe("external object new-circuit project command", () => {
|
||
it("creates circuit, protection, row and links as one undoable revision", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
const object = objectSnapshot(null);
|
||
const circuit = newExternalCircuit(context.db);
|
||
const command = createExternalObjectNewCircuitProjectCommand(
|
||
externalObjectAssignToNewCircuitCommandType,
|
||
{
|
||
circuit,
|
||
objects: [{
|
||
expected: object,
|
||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||
}],
|
||
confirmedConflictObjectIds: [],
|
||
}
|
||
);
|
||
const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db);
|
||
const inserted = repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command,
|
||
});
|
||
assert.equal(
|
||
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()!
|
||
.equipmentIdentifier,
|
||
"-2F1.3"
|
||
);
|
||
assert.equal(row(context.db, "row-new").manualQuantity, 0);
|
||
assert.equal(
|
||
context.db.select().from(circuitProtectionDevices)
|
||
.where(eq(circuitProtectionDevices.circuitId, "circuit-new")).get()!.type,
|
||
"FI_LS"
|
||
);
|
||
assert.equal(
|
||
context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId,
|
||
"row-new"
|
||
);
|
||
assert.throws(
|
||
() => repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 1,
|
||
source: "user",
|
||
command: inserted.inverse,
|
||
}),
|
||
/only be removed through project history/
|
||
);
|
||
|
||
const undone = repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 1,
|
||
source: "undo",
|
||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||
command: inserted.inverse,
|
||
});
|
||
assert.equal(
|
||
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(),
|
||
undefined
|
||
);
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||
|
||
repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 2,
|
||
source: "redo",
|
||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||
command: undone.inverse,
|
||
});
|
||
assert.equal(
|
||
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()!
|
||
.equipmentIdentifier,
|
||
"-2F1.3"
|
||
);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("rejects a preplanned BMK that does not belong to the target group", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
const object = objectSnapshot(null);
|
||
const circuit = {
|
||
...newExternalCircuit(context.db),
|
||
equipmentIdentifier: "-1F1.3",
|
||
};
|
||
assert.throws(
|
||
() => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command: createExternalObjectNewCircuitProjectCommand(
|
||
externalObjectAssignToNewCircuitCommandType,
|
||
{
|
||
circuit,
|
||
objects: [{
|
||
expected: object,
|
||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||
}],
|
||
confirmedConflictObjectIds: [],
|
||
}
|
||
),
|
||
}),
|
||
/equipment identifier does not match target group/
|
||
);
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("blocks history removal when the generated circuit became a feeder", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
const object = objectSnapshot(null);
|
||
const circuit = newExternalCircuit(context.db);
|
||
const command = createExternalObjectNewCircuitProjectCommand(
|
||
externalObjectAssignToNewCircuitCommandType,
|
||
{
|
||
circuit,
|
||
objects: [{
|
||
expected: object,
|
||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||
}],
|
||
confirmedConflictObjectIds: [],
|
||
}
|
||
);
|
||
const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db);
|
||
const inserted = repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command,
|
||
});
|
||
context.db.insert(distributionBoards).values({
|
||
id: "board-2",
|
||
projectId: "project-1",
|
||
name: "Unterverteiler",
|
||
boardNumber: "UV-02",
|
||
feedingDistributionBoardId: "board-1",
|
||
feedingCircuitId: circuit.id,
|
||
}).run();
|
||
|
||
assert.throws(
|
||
() => repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 1,
|
||
source: "undo",
|
||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||
command: inserted.inverse,
|
||
}),
|
||
/Speist UV-02 – erst Einspeisung ändern\./
|
||
);
|
||
assert.ok(
|
||
context.db.select().from(circuits)
|
||
.where(eq(circuits.id, circuit.id)).get()
|
||
);
|
||
assert.equal(
|
||
context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId,
|
||
"row-new"
|
||
);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("rolls back the complete circuit subtree when history persistence fails", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
context.sqlite.exec(`
|
||
CREATE TRIGGER fail_new_circuit_history
|
||
BEFORE INSERT ON project_change_sets
|
||
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
|
||
`);
|
||
const object = objectSnapshot(null);
|
||
assert.throws(
|
||
() => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command: createExternalObjectNewCircuitProjectCommand(
|
||
externalObjectAssignToNewCircuitCommandType,
|
||
{
|
||
circuit: newExternalCircuit(context.db),
|
||
objects: [{
|
||
expected: object,
|
||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||
}],
|
||
confirmedConflictObjectIds: [],
|
||
}
|
||
),
|
||
}),
|
||
/late history failure/
|
||
);
|
||
assert.equal(
|
||
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(),
|
||
undefined
|
||
);
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("detaches linked objects on row deletion and restores the exact link on undo", () => {
|
||
const context = createFixture();
|
||
try {
|
||
const repository = new CircuitDeviceRowStructureProjectCommandRepository(context.db);
|
||
const deleted = repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 0,
|
||
source: "user",
|
||
command: createCircuitDeviceRowDeleteProjectCommand("row-1", "circuit-1"),
|
||
});
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||
assert.equal(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get(), undefined);
|
||
|
||
repository.execute({
|
||
projectId: "project-1",
|
||
expectedRevision: 1,
|
||
source: "undo",
|
||
historyTargetChangeSetId: deleted.revision.changeSetId,
|
||
command: deleted.inverse,
|
||
});
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
|
||
assert.ok(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get());
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
|
||
it("preserves external links through circuit and populated-group deletion undo", () => {
|
||
for (const kind of ["circuit", "group"] as const) {
|
||
const context = createFixture();
|
||
try {
|
||
const repository = kind === "circuit"
|
||
? new CircuitStructureProjectCommandRepository(context.db)
|
||
: new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||
const command = kind === "circuit"
|
||
? createCircuitDeleteProjectCommand("circuit-1", "list-1")
|
||
: createCircuitGroupDeleteSubtreeProjectCommand(
|
||
(repository as CircuitGroupSubtreeProjectCommandRepository).capture("project-1", "section-1")
|
||
);
|
||
const deleted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command } as never);
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||
repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse } as never);
|
||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
|
||
} finally {
|
||
context.close();
|
||
}
|
||
}
|
||
});
|
||
|
||
it("blocks room and distribution-board deletion while external objects reference them", () => {
|
||
const context = createFixture(null);
|
||
try {
|
||
assert.throws(() => assertDistributionBoardHasNoExternalObjects(context.db, "project-1", "board-1"), /Revit-Objekte/);
|
||
assert.throws(() => assertRoomHasNoExternalObjects(context.db, "project-1", "room-1"), /Revit-Objekte/);
|
||
} finally {
|
||
context.close();
|
||
}
|
||
});
|
||
});
|