forked from jappel/leistungsbilanz-ts
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>
422 lines
13 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|