leistungsbilanz-ts/tests/project-history.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

447 lines
14 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 { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.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 { projectHistoryStackEntries } from "../src/db/schema/project-history-stack-entries.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-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" })
.run();
const board = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
assert.ok(section);
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Bestand",
sortOrder: 10,
})
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return context;
}
function getCircuitName(context: DatabaseContext) {
return context.db
.select({ displayName: circuits.displayName })
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get()?.displayName;
}
function getRowQuantity(context: DatabaseContext) {
return context.db
.select({ quantity: circuitDeviceRows.quantity })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.quantity;
}
describe("project history repository", () => {
it("lists revision metadata in stable descending pages without payloads", () => {
const context = createTestDatabase();
try {
const circuitsStore = new CircuitProjectCommandRepository(context.db);
const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db);
const first = circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
actorId: "planner-1",
description: "Stromkreis umbenennen",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
}),
});
const second = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
description: "Anzahl ändern",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
}),
});
const third = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
description: "Anzahl zurücknehmen",
historyTargetChangeSetId: second.revision.changeSetId,
command: second.inverse,
});
const history = new ProjectHistoryRepository(context.db);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 2,
}),
{
projectId: "project-1",
currentRevision: 3,
revisions: [
{
revisionId: third.revision.revisionId,
changeSetId: third.revision.changeSetId,
revisionNumber: 3,
createdAtIso: third.revision.createdAtIso,
actorId: null,
source: "undo",
description: "Anzahl zurücknehmen",
commandType: "circuit-device-row.update",
payloadSchemaVersion: 1,
},
{
revisionId: second.revision.revisionId,
changeSetId: second.revision.changeSetId,
revisionNumber: 2,
createdAtIso: second.revision.createdAtIso,
actorId: null,
source: "user",
description: "Anzahl ändern",
commandType: "circuit-device-row.update",
payloadSchemaVersion: 1,
},
],
nextBeforeRevision: 2,
}
);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 2,
beforeRevision: 2,
}),
{
projectId: "project-1",
currentRevision: 3,
revisions: [
{
revisionId: first.revision.revisionId,
changeSetId: first.revision.changeSetId,
revisionNumber: 1,
createdAtIso: first.revision.createdAtIso,
actorId: "planner-1",
source: "user",
description: "Stromkreis umbenennen",
commandType: "circuit.update",
payloadSchemaVersion: 1,
},
],
nextBeforeRevision: null,
}
);
assert.deepEqual(
history
.listRevisionsByNumbers("project-1", [3, 1, 3, 99])
.map((revision) => ({
revisionNumber: revision.revisionNumber,
description: revision.description,
})),
[
{
revisionNumber: 3,
description: "Anzahl zurücknehmen",
},
{
revisionNumber: 1,
description: "Stromkreis umbenennen",
},
]
);
assert.deepEqual(
history.listRevisionsByNumbers("project-1", [0]),
[]
);
assert.throws(
() =>
history.listRevisionsByNumbers("project-1", [-1]),
/non-negative integers/
);
} finally {
context.close();
}
});
it("lists an empty timeline and rejects invalid page inputs", () => {
const context = createTestDatabase();
try {
const history = new ProjectHistoryRepository(context.db);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 25,
}),
{
projectId: "project-1",
currentRevision: 0,
revisions: [],
nextBeforeRevision: null,
}
);
assert.equal(
history.listRevisions({
projectId: "missing",
limit: 25,
}),
null
);
assert.throws(
() =>
history.listRevisions({
projectId: "project-1",
limit: 0,
}),
/between 1 and 100/
);
assert.throws(
() =>
history.listRevisions({
projectId: "project-1",
limit: 25,
beforeRevision: 0,
}),
/positive integer/
);
} finally {
context.close();
}
});
it("persists project-wide undo and redo stacks across repository instances", () => {
const context = createTestDatabase();
try {
const circuitsStore = new CircuitProjectCommandRepository(context.db);
const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db);
const circuitCommand = createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
});
const circuitUpdate = circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: circuitCommand,
});
const rowCommand = createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
});
const rowUpdate = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: rowCommand,
});
const reloadedHistory = new ProjectHistoryRepository(context.db);
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 2,
undoDepth: 2,
redoDepth: 0,
undoChangeSetId: rowUpdate.revision.changeSetId,
redoChangeSetId: null,
});
assert.throws(
() =>
circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
command: circuitUpdate.inverse,
}),
/not the top undo command/
);
assert.equal(getCircuitName(context), "Neu");
assert.equal(
context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
2
);
rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: rowUpdate.revision.changeSetId,
command: rowUpdate.inverse,
});
assert.equal(getRowQuantity(context), 1);
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 1,
undoChangeSetId: circuitUpdate.revision.changeSetId,
redoChangeSetId: rowUpdate.revision.changeSetId,
});
circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
command: circuitUpdate.inverse,
});
assert.equal(getCircuitName(context), "Bestand");
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 4,
undoDepth: 0,
redoDepth: 2,
undoChangeSetId: null,
redoChangeSetId: circuitUpdate.revision.changeSetId,
});
circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 4,
source: "redo",
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
command: circuitCommand,
});
assert.equal(getCircuitName(context), "Neu");
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 5,
undoDepth: 1,
redoDepth: 1,
undoChangeSetId: circuitUpdate.revision.changeSetId,
redoChangeSetId: rowUpdate.revision.changeSetId,
});
const branchedRowUpdate = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 5,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 3,
}),
});
assert.equal(getRowQuantity(context), 3);
assert.deepEqual(reloadedHistory.getState("project-1"), {
projectId: "project-1",
currentRevision: 6,
undoDepth: 2,
redoDepth: 0,
undoChangeSetId: branchedRowUpdate.revision.changeSetId,
redoChangeSetId: null,
});
assert.equal(context.db.select().from(projectRevisions).all().length, 6);
assert.equal(context.db.select().from(projectChangeSets).all().length, 6);
assert.equal(
context.db.select().from(projectHistoryStackEntries).all().length,
2
);
} finally {
context.close();
}
});
it("rolls back domain and revision writes when stack persistence fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_history_stack_insert
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced history stack failure');
END;
`);
const store = new CircuitProjectCommandRepository(context.db);
assert.throws(
() =>
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Darf nicht bleiben",
}),
}),
/forced history stack failure/
);
assert.equal(getCircuitName(context), "Bestand");
assert.deepEqual(
new ProjectHistoryRepository(context.db).getState("project-1"),
{
projectId: "project-1",
currentRevision: 0,
undoDepth: 0,
redoDepth: 0,
undoChangeSetId: null,
redoChangeSetId: null,
}
);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
} finally {
context.close();
}
});
it("returns null for an unknown project", () => {
const context = createTestDatabase();
try {
assert.equal(
new ProjectHistoryRepository(context.db).getState("missing"),
null
);
} finally {
context.close();
}
});
});