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>
447 lines
14 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|