leistungsbilanz-ts/tests/circuit-device-row-project-command.repository.test.ts
Julian Appel 898be43ebc Keep Revit and manual row quantities consistent
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>
2026-10-29 14:03:29 +01:00

422 lines
13 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 DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(foreignSection);
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-2",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(projectDevices)
.values([
{
id: "project-device-1",
projectId: "project-1",
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "project-device-2",
projectId: "project-2",
name: "Fremdgerät",
displayName: "Fremdgerät",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(rooms)
.values([
{
id: "room-1",
projectId: "project-1",
floorId: null,
roomNumber: "001",
roomName: "Büro",
},
{
id: "room-2",
projectId: "project-2",
floorId: null,
roomNumber: "999",
roomName: "Fremdraum",
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte lokal",
quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "row-2",
circuitId: "circuit-2",
sortOrder: 10,
name: "Fremdzeile",
displayName: "Fremdzeile",
quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
return context;
}
function getRow(context: DatabaseContext) {
const row = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get();
assert.ok(row);
return row;
}
describe("circuit device-row project-command repository", () => {
it("commits the row update, tracked override, inverse and revision together", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
const executed = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Gerätezeile bearbeiten",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
roomId: "room-1",
remark: null,
}),
});
const row = getRow(context);
assert.equal(row.quantity, 2);
assert.equal(row.manualQuantity, 2);
assert.equal(row.roomId, "room-1");
assert.equal(row.remark, null);
assert.equal(row.overriddenFields, "[\"quantity\"]");
assert.equal(executed.revision.revisionNumber, 1);
assert.deepEqual(executed.inverse.payload, {
rowId: "row-1",
changes: [
{ field: "quantity", value: 1 },
{ field: "roomId", value: null },
{ field: "remark", value: null },
{ field: "manualQuantity", value: 1 },
{ field: "overriddenFields", value: null },
],
});
const changeSet = context.db.select().from(projectChangeSets).get();
assert.ok(changeSet);
const storedForward = deserializeProjectCommand(
changeSet.forwardPayloadJson
);
assert.deepEqual(storedForward, {
schemaVersion: 1,
type: "circuit-device-row.update",
payload: {
rowId: "row-1",
changes: [
{ field: "quantity", value: 2 },
{ field: "roomId", value: "room-1" },
{ field: "remark", value: null },
{ field: "manualQuantity", value: 2 },
{ field: "overriddenFields", value: "[\"quantity\"]" },
],
},
});
} finally {
context.close();
}
});
it("applies the generated inverse and restores override metadata", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
const forward = store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 3,
}),
});
const undone = store.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: forward.revision.changeSetId,
command: forward.inverse,
});
const row = getRow(context);
assert.equal(row.quantity, 1);
assert.equal(row.manualQuantity, 1);
assert.equal(row.overriddenFields, null);
assert.equal(undone.revision.revisionNumber, 2);
assert.deepEqual(
context.db
.select({ source: projectRevisions.source })
.from(projectRevisions)
.all()
.map((revision) => revision.source),
["user", "undo"]
);
} finally {
context.close();
}
});
it("rolls back the row and override update for a stale revision", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
}),
});
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 4,
}),
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
const row = getRow(context);
assert.equal(row.quantity, 2);
assert.equal(row.overriddenFields, "[\"quantity\"]");
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
} finally {
context.close();
}
});
it("rolls back the row update when late history persistence fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_device_row_command_change_set
BEFORE INSERT ON project_change_sets
BEGIN
SELECT RAISE(ABORT, 'forced device-row history failure');
END;
`);
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 5,
}),
}),
/forced device-row history failure/
);
const row = getRow(context);
assert.equal(row.quantity, 1);
assert.equal(row.overriddenFields, null);
assert.equal(
context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
0
);
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
} finally {
context.close();
}
});
it("rejects cross-project rows, devices and rooms without history", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-2", {
quantity: 2,
}),
}),
/does not belong to project/
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
linkedProjectDeviceId: "project-device-2",
}),
}),
/Invalid linked project device/
);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
roomId: "room-2",
}),
}),
/Invalid room/
);
assert.equal(getRow(context).linkedProjectDeviceId, "project-device-1");
assert.equal(getRow(context).roomId, null);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
it("derives the total from a manual-share edit and rejects inconsistent quantity pairs", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 1,
manualQuantity: 2,
}),
/manualQuantity must not exceed quantity/
);
assert.throws(() => store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 3,
manualQuantity: 2,
}),
}), /does not match manual and external quantity/);
assert.deepEqual([getRow(context).quantity, getRow(context).manualQuantity], [1, 1]);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
manualQuantity: 2,
}),
});
assert.deepEqual([getRow(context).quantity, getRow(context).manualQuantity], [2, 2]);
} finally {
context.close();
}
});
});