633 lines
20 KiB
TypeScript
633 lines
20 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 { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js";
|
|
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
|
|
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
|
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.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 { projectRevisions } from "../src/db/schema/project-revisions.js";
|
|
import { projects } from "../src/db/schema/projects.js";
|
|
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.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 {
|
|
createCircuitDeviceRowMoveProjectCommand,
|
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
|
|
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
|
|
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
|
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
|
|
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
|
import { ProjectCommandService } from "../src/domain/services/project-command.service.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 DistributionBoardRepository(
|
|
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,
|
|
powerPerUnit: 0.1,
|
|
simultaneityFactor: 1,
|
|
})
|
|
.run();
|
|
return context;
|
|
}
|
|
|
|
function createService(context: DatabaseContext) {
|
|
return new ProjectCommandService(
|
|
new CircuitProjectCommandRepository(context.db),
|
|
new CircuitDeviceRowProjectCommandRepository(context.db),
|
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
|
|
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
|
new CircuitStructureProjectCommandRepository(context.db),
|
|
new CircuitSectionReorderProjectCommandRepository(context.db),
|
|
new ProjectHistoryRepository(context.db)
|
|
);
|
|
}
|
|
|
|
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 command service", () => {
|
|
it("dispatches supported commands and persists undo/redo across service instances", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const service = createService(context);
|
|
const circuitResult = service.executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
|
displayName: "Neu",
|
|
}),
|
|
});
|
|
assert.equal(circuitResult.revision.revisionNumber, 1);
|
|
assert.equal(circuitResult.history.undoDepth, 1);
|
|
assert.equal(getCircuitName(context), "Neu");
|
|
|
|
const rowResult = service.executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
|
quantity: 2,
|
|
}),
|
|
});
|
|
assert.equal(rowResult.revision.revisionNumber, 2);
|
|
assert.equal(rowResult.history.undoDepth, 2);
|
|
assert.equal(getRowQuantity(context), 2);
|
|
|
|
const reloadedService = createService(context);
|
|
const firstUndo = reloadedService.undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 2,
|
|
});
|
|
assert.equal(firstUndo.revision.revisionNumber, 3);
|
|
assert.equal(firstUndo.history.undoDepth, 1);
|
|
assert.equal(firstUndo.history.redoDepth, 1);
|
|
assert.equal(getRowQuantity(context), 1);
|
|
|
|
const secondUndo = reloadedService.undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 3,
|
|
});
|
|
assert.equal(secondUndo.revision.revisionNumber, 4);
|
|
assert.equal(secondUndo.history.undoDepth, 0);
|
|
assert.equal(secondUndo.history.redoDepth, 2);
|
|
assert.equal(getCircuitName(context), "Bestand");
|
|
|
|
const redo = reloadedService.redo({
|
|
projectId: "project-1",
|
|
expectedRevision: 4,
|
|
});
|
|
assert.equal(redo.revision.revisionNumber, 5);
|
|
assert.equal(redo.history.undoDepth, 1);
|
|
assert.equal(redo.history.redoDepth, 1);
|
|
assert.equal(getCircuitName(context), "Neu");
|
|
assert.deepEqual(
|
|
context.db
|
|
.select({ source: projectRevisions.source })
|
|
.from(projectRevisions)
|
|
.all()
|
|
.map((row) => row.source),
|
|
["user", "user", "undo", "undo", "redo"]
|
|
);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("rejects stale undo and preserves the domain value and stack", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const service = createService(context);
|
|
const changed = service.executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
|
displayName: "Neu",
|
|
}),
|
|
});
|
|
|
|
assert.throws(
|
|
() =>
|
|
service.undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
}),
|
|
(error) =>
|
|
error instanceof ProjectRevisionConflictError &&
|
|
error.actualRevision === 1
|
|
);
|
|
assert.equal(getCircuitName(context), "Neu");
|
|
assert.deepEqual(
|
|
new ProjectHistoryRepository(context.db).getState("project-1"),
|
|
changed.history
|
|
);
|
|
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
|
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("dispatches device-row insertion and its persisted inverse", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const service = createService(context);
|
|
const inserted = service.executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command: createCircuitDeviceRowInsertProjectCommand({
|
|
id: "row-2",
|
|
circuitId: "circuit-1",
|
|
linkedProjectDeviceId: null,
|
|
legacyConsumerId: null,
|
|
sortOrder: 20,
|
|
name: "Steckdose",
|
|
displayName: "Steckdose",
|
|
phaseType: "single_phase",
|
|
connectionKind: null,
|
|
costGroup: null,
|
|
category: null,
|
|
level: null,
|
|
roomId: null,
|
|
roomNumberSnapshot: null,
|
|
roomNameSnapshot: null,
|
|
quantity: 1,
|
|
powerPerUnit: 0.2,
|
|
simultaneityFactor: 1,
|
|
cosPhi: null,
|
|
remark: null,
|
|
overriddenFields: null,
|
|
}),
|
|
});
|
|
assert.equal(inserted.history.undoDepth, 1);
|
|
assert.equal(
|
|
context.db
|
|
.select()
|
|
.from(circuitDeviceRows)
|
|
.where(eq(circuitDeviceRows.id, "row-2"))
|
|
.get()?.displayName,
|
|
"Steckdose"
|
|
);
|
|
|
|
const undone = createService(context).undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
});
|
|
assert.equal(undone.history.redoDepth, 1);
|
|
assert.equal(
|
|
context.db
|
|
.select()
|
|
.from(circuitDeviceRows)
|
|
.where(eq(circuitDeviceRows.id, "row-2"))
|
|
.get(),
|
|
undefined
|
|
);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("dispatches complete circuit insertion and its persisted inverse", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const existingCircuit = context.db
|
|
.select()
|
|
.from(circuits)
|
|
.where(eq(circuits.id, "circuit-1"))
|
|
.get();
|
|
assert.ok(existingCircuit);
|
|
const service = createService(context);
|
|
const inserted = service.executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command: createCircuitInsertProjectCommand({
|
|
id: "circuit-new",
|
|
circuitListId: existingCircuit.circuitListId,
|
|
sectionId: existingCircuit.sectionId,
|
|
equipmentIdentifier: "-1F2",
|
|
displayName: "Reserve",
|
|
sortOrder: 20,
|
|
protectionType: null,
|
|
protectionRatedCurrent: null,
|
|
protectionCharacteristic: null,
|
|
cableType: null,
|
|
cableCrossSection: null,
|
|
cableLength: null,
|
|
rcdAssignment: null,
|
|
terminalDesignation: null,
|
|
voltage: 230,
|
|
controlRequirement: null,
|
|
status: null,
|
|
isReserve: true,
|
|
remark: null,
|
|
deviceRows: [],
|
|
}),
|
|
});
|
|
assert.equal(inserted.history.undoDepth, 1);
|
|
assert.equal(
|
|
context.db
|
|
.select()
|
|
.from(circuits)
|
|
.where(eq(circuits.id, "circuit-new"))
|
|
.get()?.equipmentIdentifier,
|
|
"-1F2"
|
|
);
|
|
|
|
const undone = createService(context).undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
});
|
|
assert.equal(undone.history.redoDepth, 1);
|
|
assert.equal(
|
|
context.db
|
|
.select()
|
|
.from(circuits)
|
|
.where(eq(circuits.id, "circuit-new"))
|
|
.get(),
|
|
undefined
|
|
);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("dispatches deterministic device-row moves and their inverse", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const sourceCircuit = context.db
|
|
.select()
|
|
.from(circuits)
|
|
.where(eq(circuits.id, "circuit-1"))
|
|
.get();
|
|
assert.ok(sourceCircuit);
|
|
context.db
|
|
.insert(circuits)
|
|
.values({
|
|
id: "circuit-2",
|
|
circuitListId: sourceCircuit.circuitListId,
|
|
sectionId: sourceCircuit.sectionId,
|
|
equipmentIdentifier: "-1F2",
|
|
sortOrder: 20,
|
|
isReserve: 1,
|
|
})
|
|
.run();
|
|
|
|
const service = createService(context);
|
|
const moved = service.executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command: createCircuitDeviceRowMoveProjectCommand([
|
|
{
|
|
rowId: "row-1",
|
|
expectedCircuitId: "circuit-1",
|
|
expectedSortOrder: 10,
|
|
targetCircuitId: "circuit-2",
|
|
targetSortOrder: 10,
|
|
},
|
|
]),
|
|
});
|
|
assert.equal(moved.history.undoDepth, 1);
|
|
assert.equal(
|
|
context.db
|
|
.select()
|
|
.from(circuitDeviceRows)
|
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
|
.get()?.circuitId,
|
|
"circuit-2"
|
|
);
|
|
|
|
createService(context).undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
});
|
|
assert.equal(
|
|
context.db
|
|
.select()
|
|
.from(circuitDeviceRows)
|
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
|
.get()?.circuitId,
|
|
"circuit-1"
|
|
);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("dispatches placeholder moves that create and remove a circuit", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const sourceCircuit = context.db
|
|
.select()
|
|
.from(circuits)
|
|
.where(eq(circuits.id, "circuit-1"))
|
|
.get();
|
|
assert.ok(sourceCircuit);
|
|
const targetCircuit = {
|
|
id: "circuit-placeholder-target",
|
|
circuitListId: sourceCircuit.circuitListId,
|
|
sectionId: sourceCircuit.sectionId,
|
|
equipmentIdentifier: "-1F2",
|
|
displayName: "Neuer Stromkreis",
|
|
sortOrder: 20,
|
|
protectionType: null,
|
|
protectionRatedCurrent: null,
|
|
protectionCharacteristic: null,
|
|
cableType: null,
|
|
cableCrossSection: null,
|
|
cableLength: null,
|
|
rcdAssignment: null,
|
|
terminalDesignation: null,
|
|
voltage: null,
|
|
controlRequirement: null,
|
|
status: null,
|
|
isReserve: true,
|
|
remark: null,
|
|
deviceRows: [],
|
|
};
|
|
|
|
const moved = createService(context).executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command:
|
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
|
"create",
|
|
targetCircuit,
|
|
[
|
|
{
|
|
rowId: "row-1",
|
|
expectedCircuitId: "circuit-1",
|
|
expectedSortOrder: 10,
|
|
targetCircuitId: targetCircuit.id,
|
|
targetSortOrder: 10,
|
|
},
|
|
]
|
|
),
|
|
});
|
|
assert.equal(moved.history.undoDepth, 1);
|
|
assert.equal(
|
|
context.db
|
|
.select()
|
|
.from(circuitDeviceRows)
|
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
|
.get()?.circuitId,
|
|
targetCircuit.id
|
|
);
|
|
|
|
createService(context).undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
});
|
|
assert.equal(
|
|
context.db
|
|
.select({ id: circuits.id })
|
|
.from(circuits)
|
|
.where(eq(circuits.id, targetCircuit.id))
|
|
.get(),
|
|
undefined
|
|
);
|
|
assert.equal(
|
|
context.db
|
|
.select()
|
|
.from(circuitDeviceRows)
|
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
|
.get()?.circuitId,
|
|
"circuit-1"
|
|
);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("dispatches complete section reorders without changing identifiers", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const firstCircuit = context.db
|
|
.select()
|
|
.from(circuits)
|
|
.where(eq(circuits.id, "circuit-1"))
|
|
.get();
|
|
assert.ok(firstCircuit);
|
|
context.db
|
|
.insert(circuits)
|
|
.values({
|
|
id: "circuit-2",
|
|
circuitListId: firstCircuit.circuitListId,
|
|
sectionId: firstCircuit.sectionId,
|
|
equipmentIdentifier: "-1F2",
|
|
sortOrder: 20,
|
|
isReserve: 1,
|
|
})
|
|
.run();
|
|
|
|
const reordered = createService(context).executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command: createCircuitSectionReorderProjectCommand(
|
|
firstCircuit.sectionId,
|
|
[
|
|
{
|
|
circuitId: "circuit-1",
|
|
expectedSortOrder: 10,
|
|
targetSortOrder: 20,
|
|
},
|
|
{
|
|
circuitId: "circuit-2",
|
|
expectedSortOrder: 20,
|
|
targetSortOrder: 10,
|
|
},
|
|
]
|
|
),
|
|
});
|
|
assert.equal(reordered.history.undoDepth, 1);
|
|
assert.deepEqual(
|
|
context.db
|
|
.select({
|
|
id: circuits.id,
|
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
|
sortOrder: circuits.sortOrder,
|
|
})
|
|
.from(circuits)
|
|
.all()
|
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
|
[
|
|
{
|
|
id: "circuit-1",
|
|
equipmentIdentifier: "-1F1",
|
|
sortOrder: 20,
|
|
},
|
|
{
|
|
id: "circuit-2",
|
|
equipmentIdentifier: "-1F2",
|
|
sortOrder: 10,
|
|
},
|
|
]
|
|
);
|
|
|
|
createService(context).undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 1,
|
|
});
|
|
assert.deepEqual(
|
|
context.db
|
|
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
|
|
.from(circuits)
|
|
.all()
|
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
|
[
|
|
{ id: "circuit-1", sortOrder: 10 },
|
|
{ id: "circuit-2", sortOrder: 20 },
|
|
]
|
|
);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("rejects unavailable history directions without writing a revision", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const service = createService(context);
|
|
assert.throws(
|
|
() =>
|
|
service.undo({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
}),
|
|
(error) =>
|
|
error instanceof ProjectHistoryOperationUnavailableError &&
|
|
error.direction === "undo"
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
service.redo({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
}),
|
|
(error) =>
|
|
error instanceof ProjectHistoryOperationUnavailableError &&
|
|
error.direction === "redo"
|
|
);
|
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("rejects unsupported and malformed commands before domain writes", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const service = createService(context);
|
|
assert.throws(
|
|
() =>
|
|
service.executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command: {
|
|
schemaVersion: 1,
|
|
type: "unknown.command",
|
|
payload: {},
|
|
},
|
|
}),
|
|
/Unsupported project command type/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
service.executeUser({
|
|
projectId: "project-1",
|
|
expectedRevision: 0,
|
|
command: {
|
|
schemaVersion: 1,
|
|
type: "circuit.update",
|
|
payload: { circuitId: "circuit-1", changes: [] },
|
|
},
|
|
}),
|
|
/at least one change/
|
|
);
|
|
assert.equal(getCircuitName(context), "Bestand");
|
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
});
|