forked from jappel/leistungsbilanz-ts
Integrate print view and document branding
This commit is contained in:
commit
4643c8d11a
59 changed files with 8852 additions and 75 deletions
|
|
@ -387,6 +387,110 @@ describe("distribution-board structure project command", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("updates the info card fields with persisted undo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new DistributionBoardStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV-EG",
|
||||
{ floorId: null, supplyType: "AV" }
|
||||
);
|
||||
const insert = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createDistributionBoardInsertProjectCommand(structure),
|
||||
});
|
||||
const readInfo = () =>
|
||||
context.db
|
||||
.select({
|
||||
boardNumber: distributionBoards.boardNumber,
|
||||
location: distributionBoards.location,
|
||||
fedFrom: distributionBoards.fedFrom,
|
||||
remark: distributionBoards.remark,
|
||||
})
|
||||
.from(distributionBoards)
|
||||
.get();
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
});
|
||||
|
||||
const forward = repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createDistributionBoardUpdateProjectCommand(
|
||||
structure.distributionBoard.id,
|
||||
{
|
||||
boardNumber: "UV 0.1",
|
||||
location: "Raum 003 Technik",
|
||||
fedFrom: "HAK",
|
||||
}
|
||||
),
|
||||
});
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: "UV 0.1",
|
||||
location: "Raum 003 Technik",
|
||||
fedFrom: "HAK",
|
||||
remark: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
forward.inverse.payload.changes.map((change) => change.field),
|
||||
["boardNumber", "location", "fedFrom"]
|
||||
);
|
||||
|
||||
// A board with info values is no longer the unchanged empty setup.
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "user",
|
||||
command: insert.inverse,
|
||||
}),
|
||||
/changed before deletion/
|
||||
);
|
||||
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: forward.inverse,
|
||||
});
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
});
|
||||
|
||||
for (const value of ["", " UV 1", "x".repeat(51)]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
createDistributionBoardUpdateProjectCommand(
|
||||
structure.distributionBoard.id,
|
||||
{ boardNumber: value }
|
||||
),
|
||||
/boardNumber/
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("sends distribution-board writes to the revision-safe API", async () => {
|
||||
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
|
|
|||
|
|
@ -192,6 +192,67 @@ describe("distribution-board subtree project command", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("copies board info values and accepts snapshots stored before they existed", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const board = createPopulatedBoard(context);
|
||||
context.db
|
||||
.update(distributionBoards)
|
||||
.set({ boardNumber: "UV 1.1", location: "Technik" })
|
||||
.where(eq(distributionBoards.id, board.id))
|
||||
.run();
|
||||
const repository =
|
||||
new DistributionBoardSubtreeProjectCommandRepository(context.db);
|
||||
const withInfo = repository.capture("project-1", board.id);
|
||||
const clone = cloneDistributionBoardSubtree(withInfo, "UV 1 Kopie");
|
||||
assert.equal(clone.distributionBoard.boardNumber, "UV 1.1");
|
||||
assert.equal(clone.distributionBoard.location, "Technik");
|
||||
|
||||
context.db
|
||||
.update(distributionBoards)
|
||||
.set({ boardNumber: null, location: null })
|
||||
.where(eq(distributionBoards.id, board.id))
|
||||
.run();
|
||||
const legacySnapshot = structuredClone(
|
||||
repository.capture("project-1", board.id)
|
||||
);
|
||||
const legacyBoard = legacySnapshot.distributionBoard as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete legacyBoard.boardNumber;
|
||||
delete legacyBoard.location;
|
||||
delete legacyBoard.fedFrom;
|
||||
delete legacyBoard.remark;
|
||||
|
||||
const deleted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
description: "Verteilung löschen",
|
||||
command: createDistributionBoardDeleteSubtreeProjectCommand(
|
||||
legacySnapshot
|
||||
),
|
||||
});
|
||||
assert.throws(
|
||||
() => repository.capture("project-1", board.id),
|
||||
/not found/
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: deleted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
repository.capture("project-1", board.id).distributionBoard.fedFrom,
|
||||
null
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects changed deletion snapshots without partial writes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
|
|
|||
77
tests/document-branding.repository.test.ts
Normal file
77
tests/document-branding.repository.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||
import { DocumentBrandingRepository } from "../src/db/repositories/document-branding.repository.js";
|
||||
import { officeProfileFields } from "../src/shared/constants/project-cover.js";
|
||||
import { updateOfficeProfileSchema } from "../src/shared/validation/document-branding.schemas.js";
|
||||
|
||||
function createRepository() {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
return { context, repository: new DocumentBrandingRepository(context.db) };
|
||||
}
|
||||
|
||||
describe("document branding repository", () => {
|
||||
it("stores, replaces and removes the office profile", () => {
|
||||
const { context, repository } = createRepository();
|
||||
try {
|
||||
assert.deepEqual(repository.getSettings(officeProfileFields), {
|
||||
officeName: null,
|
||||
officeAddress: null,
|
||||
});
|
||||
repository.setSettings(
|
||||
updateOfficeProfileSchema.parse({
|
||||
officeName: " kbp köhler beraten + planen GmbH ",
|
||||
officeAddress: "Straße 1\n12345 Ort",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(repository.getSettings(officeProfileFields), {
|
||||
officeName: "kbp köhler beraten + planen GmbH",
|
||||
officeAddress: "Straße 1\n12345 Ort",
|
||||
});
|
||||
repository.setSettings(
|
||||
updateOfficeProfileSchema.parse({ officeName: "Büro", officeAddress: "" })
|
||||
);
|
||||
assert.deepEqual(repository.getSettings(officeProfileFields), {
|
||||
officeName: "Büro",
|
||||
officeAddress: null,
|
||||
});
|
||||
assert.equal(
|
||||
updateOfficeProfileSchema.safeParse({ officeName: "x".repeat(201), officeAddress: null })
|
||||
.success,
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces and deletes the logo", () => {
|
||||
const { context, repository } = createRepository();
|
||||
try {
|
||||
repository.upsert({
|
||||
key: "logo",
|
||||
mimeType: "image/png",
|
||||
fileName: "a.png",
|
||||
data: Buffer.from([1, 2, 3]),
|
||||
});
|
||||
repository.upsert({
|
||||
key: "logo",
|
||||
mimeType: "image/svg+xml",
|
||||
fileName: "b.svg",
|
||||
data: Buffer.from("<svg/>"),
|
||||
});
|
||||
const logo = repository.get("logo");
|
||||
assert.equal(logo?.mimeType, "image/svg+xml");
|
||||
assert.equal(logo?.data.toString(), "<svg/>");
|
||||
assert.equal(repository.delete("logo"), true);
|
||||
assert.equal(repository.get("logo"), undefined);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
207
tests/print-document-model.test.ts
Normal file
207
tests/print-document-model.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
assertPrintRevisionConsistency,
|
||||
buildPreliminaryBoardHierarchy,
|
||||
formatCircuitCable,
|
||||
formatProtectionDevice,
|
||||
summarizePrintBoard,
|
||||
} from "../src/frontend/print/print-document-model.js";
|
||||
import type {
|
||||
CircuitTreeResponseDto,
|
||||
DistributionBoardDto,
|
||||
} from "../src/frontend/types.js";
|
||||
|
||||
function board(
|
||||
id: string,
|
||||
info: Partial<DistributionBoardDto> = {}
|
||||
): DistributionBoardDto {
|
||||
return {
|
||||
id,
|
||||
projectId: "project-1",
|
||||
name: id,
|
||||
floorId: null,
|
||||
supplyType: "AV",
|
||||
simultaneityFactor: 1,
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
...info,
|
||||
};
|
||||
}
|
||||
|
||||
function tree(
|
||||
rows: Array<{
|
||||
quantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
}>,
|
||||
boardSimultaneityFactor = 1
|
||||
): CircuitTreeResponseDto {
|
||||
const deviceRows = rows.map((row, index) => ({
|
||||
id: `row-${index}`,
|
||||
sortOrder: index,
|
||||
name: "Gerät",
|
||||
displayName: "Gerät",
|
||||
manualQuantity: row.quantity,
|
||||
rowTotalPower: row.quantity * row.powerPerUnit * row.simultaneityFactor,
|
||||
...row,
|
||||
}));
|
||||
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
|
||||
return {
|
||||
circuitListId: "list",
|
||||
currentRevision: 1,
|
||||
singlePhaseVoltageV: 230,
|
||||
threePhaseVoltageV: 400,
|
||||
distributionBoardSimultaneityFactor: boardSimultaneityFactor,
|
||||
distributionBoardTotalPower: total,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor:
|
||||
total * boardSimultaneityFactor,
|
||||
headerComponents: [],
|
||||
footerComponents: [],
|
||||
sections: [
|
||||
{
|
||||
id: "section",
|
||||
key: "single_phase",
|
||||
displayName: "1-phasig 1",
|
||||
prefix: "-2F1.",
|
||||
sortOrder: 1,
|
||||
sectionTotalPower: total,
|
||||
components: [],
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit",
|
||||
circuitListId: "list",
|
||||
sectionId: "section",
|
||||
equipmentIdentifier: "-2F1.1",
|
||||
sortOrder: 1,
|
||||
isReserve: false,
|
||||
circuitTotalPower: total,
|
||||
deviceRows,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
info: Partial<DistributionBoardDto>,
|
||||
demandKw: number
|
||||
) {
|
||||
return summarizePrintBoard({
|
||||
board: board(id, info),
|
||||
circuitListId: id,
|
||||
floorName: null,
|
||||
tree: tree([{ quantity: 1, powerPerUnit: demandKw, simultaneityFactor: 1 }]),
|
||||
});
|
||||
}
|
||||
|
||||
describe("print document model", () => {
|
||||
it("rejects mixed project revisions instead of printing mislabeled data", () => {
|
||||
const summaries = [summary("uv", {}, 1)];
|
||||
assert.doesNotThrow(() => assertPrintRevisionConsistency(1, summaries, 1));
|
||||
assert.throws(() => assertPrintRevisionConsistency(0, summaries, 0), /neu laden/);
|
||||
assert.throws(() => assertPrintRevisionConsistency(1, summaries, 2), /neu laden/);
|
||||
assert.throws(() => assertPrintRevisionConsistency(1, [], 2), /neu laden/);
|
||||
});
|
||||
|
||||
it("prints both cable type and cross-section without hiding either value", () => {
|
||||
const circuit = tree([]).sections[0].circuits[0];
|
||||
assert.equal(formatCircuitCable({ ...circuit, cableType: "NYM-J", cableCrossSection: "3x2,5" }), "NYM-J · 3x2,5");
|
||||
assert.equal(formatCircuitCable({ ...circuit, cableType: "NYM-J" }), "NYM-J");
|
||||
assert.equal(formatCircuitCable({ ...circuit, cableCrossSection: "3x2,5" }), "3x2,5");
|
||||
assert.equal(formatCircuitCable(circuit), null);
|
||||
});
|
||||
|
||||
it("does not guess a parent when feed text matches more than one board", () => {
|
||||
const summaries = [
|
||||
summary("a", { boardNumber: "HV" }, 2),
|
||||
summary("b", { name: "HV" }, 3),
|
||||
summary("uv", { fedFrom: " hv " }, 1),
|
||||
];
|
||||
for (const ordered of [summaries, [...summaries].reverse()]) {
|
||||
const entries = buildPreliminaryBoardHierarchy(ordered);
|
||||
assert.ok(entries.every((entry) => entry.parentBoardId === null));
|
||||
assert.equal(entries.reduce((sum, entry) => sum + entry.cumulatedDemandPowerKw, 0), 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives installed, device and demand power and the design current", () => {
|
||||
const result = summarizePrintBoard({
|
||||
board: board("uv"),
|
||||
circuitListId: "uv",
|
||||
floorName: "EG",
|
||||
tree: tree(
|
||||
[
|
||||
{ quantity: 4, powerPerUnit: 2, simultaneityFactor: 0.5, cosPhi: 0.8 },
|
||||
{ quantity: 1, powerPerUnit: 6, simultaneityFactor: 1 },
|
||||
],
|
||||
0.5
|
||||
),
|
||||
});
|
||||
assert.equal(result.circuitCount, 1);
|
||||
assert.equal(result.installedPowerKw, 14);
|
||||
assert.equal(result.devicePowerKw, 10);
|
||||
assert.equal(result.demandPowerKw, 5);
|
||||
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
|
||||
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
|
||||
});
|
||||
|
||||
it("nests boards by matching free-text feed and sums downstream demand", () => {
|
||||
const entries = buildPreliminaryBoardHierarchy([
|
||||
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
|
||||
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4),
|
||||
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2),
|
||||
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => [
|
||||
entry.summary.board.id,
|
||||
entry.depth,
|
||||
entry.parentBoardId,
|
||||
entry.cumulatedDemandPowerKw,
|
||||
]),
|
||||
[
|
||||
["hv", 0, null, 16],
|
||||
["uv-a", 1, "hv", 6],
|
||||
["uv-b", 2, "uv-a", 2],
|
||||
["uv-c", 0, null, 1],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps boards with cyclic feed texts at the top level", () => {
|
||||
const entries = buildPreliminaryBoardHierarchy([
|
||||
summary("a", { boardNumber: "A", fedFrom: "B" }, 1),
|
||||
summary("b", { boardNumber: "B", fedFrom: "A" }, 1),
|
||||
]);
|
||||
assert.equal(entries.length, 2);
|
||||
assert.ok(entries.some((entry) => entry.parentBoardId === null));
|
||||
assert.equal(
|
||||
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
it("formats protection devices for the plan table", () => {
|
||||
assert.equal(
|
||||
formatProtectionDevice({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }),
|
||||
"LS B 16 A"
|
||||
);
|
||||
assert.equal(
|
||||
formatProtectionDevice({
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
}),
|
||||
"FI/LS B 16 A, 30 mA Typ A"
|
||||
);
|
||||
assert.equal(formatProtectionDevice(undefined), null);
|
||||
});
|
||||
});
|
||||
55
tests/print-view-migration.test.ts
Normal file
55
tests/print-view-migration.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { it } from "node:test";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||
|
||||
it("upgrades a populated pre-print database without changing its existing records", async () => {
|
||||
const source = path.resolve("src/db/migrations");
|
||||
const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-print-migration-"));
|
||||
const journal = JSON.parse(readFileSync(path.join(source, "meta/_journal.json"), "utf8"));
|
||||
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 6);
|
||||
mkdirSync(path.join(staging, "meta"));
|
||||
const files = [path.join(staging, "meta/_journal.json")];
|
||||
writeFileSync(files[0], JSON.stringify(journal));
|
||||
for (const entry of journal.entries) {
|
||||
const file = path.join(staging, `${entry.tag}.sql`);
|
||||
copyFileSync(path.join(source, `${entry.tag}.sql`), file);
|
||||
files.push(file);
|
||||
}
|
||||
const context = createDatabaseContext(":memory:");
|
||||
try {
|
||||
migrate(context.db, { migrationsFolder: staging });
|
||||
context.sqlite.exec(`
|
||||
INSERT INTO projects (id, name, current_revision) VALUES ('p', 'Existing project', 17);
|
||||
INSERT INTO distribution_boards (id, project_id, name, simultaneity_factor)
|
||||
VALUES ('b', 'p', 'Existing board', 0.7);
|
||||
`);
|
||||
const oldProject = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
|
||||
const oldBoard = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
|
||||
const backup = path.join(staging, "before-print.db");
|
||||
files.push(backup);
|
||||
await context.sqlite.backup(backup);
|
||||
|
||||
migrate(context.db, { migrationsFolder: source });
|
||||
const project = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
|
||||
const board = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(oldProject)) assert.deepEqual(project[key], value);
|
||||
for (const [key, value] of Object.entries(oldBoard)) assert.deepEqual(board[key], value);
|
||||
for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null);
|
||||
for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null);
|
||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
|
||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);
|
||||
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
|
||||
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
|
||||
migrate(context.db, { migrationsFolder: source });
|
||||
assert.deepEqual(context.sqlite.prepare("SELECT * FROM projects").get(), project);
|
||||
} finally {
|
||||
context.close();
|
||||
for (const file of files) unlinkSync(file);
|
||||
rmdirSync(path.join(staging, "meta"));
|
||||
rmdirSync(staging);
|
||||
}
|
||||
});
|
||||
|
|
@ -1074,6 +1074,10 @@ describe("project command service", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
}),
|
||||
});
|
||||
assert.equal(updated.history.currentRevision, 1);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ function getSettings(context: DatabaseContext) {
|
|||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
siteAddress: projects.siteAddress,
|
||||
workStage: projects.workStage,
|
||||
projectLead: projects.projectLead,
|
||||
projectEditor: projects.projectEditor,
|
||||
currentRevision: projects.currentRevision,
|
||||
})
|
||||
.from(projects)
|
||||
|
|
@ -81,6 +85,10 @@ function settings(
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -217,6 +225,10 @@ describe("project settings project-command repository", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -289,6 +301,10 @@ describe("project settings project-command repository", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
});
|
||||
|
||||
const undoStep = history.getNextCommand("project-1", "undo");
|
||||
|
|
@ -317,6 +333,10 @@ describe("project settings project-command repository", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
currentRevision: 2,
|
||||
});
|
||||
assert.deepEqual(getDerivedVoltages(context), {
|
||||
|
|
@ -356,6 +376,10 @@ describe("project settings project-command repository", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
currentRevision: 3,
|
||||
});
|
||||
assert.deepEqual(getDerivedVoltages(context), {
|
||||
|
|
@ -433,6 +457,117 @@ describe("project settings project-command repository", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("persists the print cover fields with persisted undo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new ProjectSettingsProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const readCover = () =>
|
||||
context.db
|
||||
.select({
|
||||
siteAddress: projects.siteAddress,
|
||||
workStage: projects.workStage,
|
||||
projectLead: projects.projectLead,
|
||||
projectEditor: projects.projectEditor,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, "project-1"))
|
||||
.get();
|
||||
const forward = repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createProjectSettingsUpdateProjectCommand(
|
||||
settings({
|
||||
siteAddress: "Musterstraße 1\n12345 Musterstadt",
|
||||
workStage: "LPH 5",
|
||||
projectLead: "P. Leitung",
|
||||
projectEditor: "B. Arbeiter",
|
||||
})
|
||||
),
|
||||
});
|
||||
assert.deepEqual(readCover(), {
|
||||
siteAddress: "Musterstraße 1\n12345 Musterstadt",
|
||||
workStage: "LPH 5",
|
||||
projectLead: "P. Leitung",
|
||||
projectEditor: "B. Arbeiter",
|
||||
});
|
||||
assert.equal(forward.inverse.schemaVersion, 3);
|
||||
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: forward.inverse,
|
||||
});
|
||||
assert.deepEqual(readCover(), {
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
createProjectSettingsUpdateProjectCommand(
|
||||
settings({ workStage: " LPH 5" })
|
||||
),
|
||||
/invalid values/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the cover fields when applying a version 2 command", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db
|
||||
.update(projects)
|
||||
.set({ workStage: "LPH 3", projectLead: "P. Leitung" })
|
||||
.where(eq(projects.id, "project-1"))
|
||||
.run();
|
||||
const repository = new ProjectSettingsProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const {
|
||||
siteAddress: _siteAddress,
|
||||
workStage: _workStage,
|
||||
projectLead: _projectLead,
|
||||
projectEditor: _projectEditor,
|
||||
...versionTwoPayload
|
||||
} = settings({ name: "Stand aus Version 2" });
|
||||
const forward = repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: {
|
||||
schemaVersion: 2,
|
||||
type: "project.update-settings",
|
||||
payload: versionTwoPayload,
|
||||
},
|
||||
});
|
||||
const project = context.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, "project-1"))
|
||||
.get();
|
||||
assert.equal(project?.name, "Stand aus Version 2");
|
||||
assert.equal(project?.workStage, "LPH 3");
|
||||
assert.equal(project?.projectLead, "P. Leitung");
|
||||
assert.equal(forward.inverse.schemaVersion, 2);
|
||||
assert.equal(Object.keys(forward.inverse.payload).length, 9);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the project supply-type selection and rejects disabling an in-use type", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -377,12 +377,75 @@ describe("project snapshot repository", () => {
|
|||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("restores snapshots stored with an older supported schema version", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new ProjectSnapshotRepository(context.db);
|
||||
const snapshot = repository.createNamed({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
name: "Vor dem Update",
|
||||
});
|
||||
assert.ok(snapshot);
|
||||
// Rewrite the stored payload into the previous (version 5) shape, as it
|
||||
// exists for snapshots created before the current version.
|
||||
const payload = JSON.parse(
|
||||
context.db
|
||||
.select({ payloadJson: projectSnapshots.payloadJson })
|
||||
.from(projectSnapshots)
|
||||
.where(eq(projectSnapshots.id, snapshot.id))
|
||||
.get()!.payloadJson
|
||||
) as {
|
||||
schemaVersion: number;
|
||||
project: Record<string, unknown>;
|
||||
distributionBoards: Array<Record<string, unknown>>;
|
||||
};
|
||||
payload.schemaVersion = 5;
|
||||
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
|
||||
delete payload.project[field];
|
||||
}
|
||||
for (const board of payload.distributionBoards) {
|
||||
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
|
||||
delete board[field];
|
||||
}
|
||||
}
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
context.db
|
||||
.update(projectSnapshots)
|
||||
.set({
|
||||
schemaVersion: 5,
|
||||
payloadJson,
|
||||
payloadSha256: crypto
|
||||
.createHash("sha256")
|
||||
.update(payloadJson)
|
||||
.digest("hex"),
|
||||
})
|
||||
.where(eq(projectSnapshots.id, snapshot.id))
|
||||
.run();
|
||||
|
||||
const prepared = repository.prepareRestore("project-1", snapshot.id);
|
||||
assert.ok(prepared);
|
||||
const targetState = prepared.command.payload.targetState;
|
||||
assert.equal(targetState.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||
assert.ok(targetState.distributionBoards.length > 0);
|
||||
assert.ok(
|
||||
targetState.distributionBoards.every(
|
||||
(board) => board.boardNumber === null && board.fedFrom === null
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("portable project transfer", () => {
|
||||
it("exports and atomically duplicates a complete project with new ids", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db.update(projects).set({ siteAddress: "Musterstraße 1", workStage: "LPH 5", projectLead: "Planung", projectEditor: "Test" }).where(eq(projects.id, "project-1")).run();
|
||||
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "Technik", fedFrom: "HAK", remark: "Prüfstand" }).where(eq(distributionBoards.projectId, "project-1")).run();
|
||||
const repository = new ProjectTransferRepository(context.db);
|
||||
const transfer = repository.exportProject("project-1");
|
||||
assert.ok(transfer);
|
||||
|
|
@ -398,6 +461,13 @@ describe("portable project transfer", () => {
|
|||
assert.ok(source);
|
||||
assert.ok(copied);
|
||||
assert.equal(copied.currentRevision, 0);
|
||||
assert.equal(copied.state.project.siteAddress, source.state.project.siteAddress);
|
||||
assert.equal(copied.state.project.workStage, "LPH 5");
|
||||
assert.equal(copied.state.project.projectLead, "Planung");
|
||||
assert.equal(copied.state.project.projectEditor, "Test");
|
||||
for (const field of ["boardNumber", "location", "fedFrom", "remark"] as const) {
|
||||
assert.equal(copied.state.distributionBoards[0][field], source.state.distributionBoards[0][field]);
|
||||
}
|
||||
assert.equal(copied.state.circuits.length, source.state.circuits.length);
|
||||
assert.notEqual(
|
||||
copied.state.circuits[0].id,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
|||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||
import { floors } from "../src/db/schema/floors.js";
|
||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
|
|
@ -340,6 +341,8 @@ describe("project state restore command", () => {
|
|||
it("restores a complete snapshot as a revision and supports undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db.update(projects).set({ siteAddress: "Originaladresse", workStage: "LPH 3", projectLead: "Leitung", projectEditor: "Bearbeitung" }).where(eq(projects.id, "project-1")).run();
|
||||
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "EG", fedFrom: "HAK", remark: "Original" }).where(eq(distributionBoards.projectId, "project-1")).run();
|
||||
const snapshots = new ProjectSnapshotRepository(context.db);
|
||||
const snapshot = snapshots.createNamed({
|
||||
projectId: "project-1",
|
||||
|
|
@ -351,6 +354,8 @@ describe("project state restore command", () => {
|
|||
assert.ok(original);
|
||||
|
||||
changeCompleteProjectState(context);
|
||||
context.db.update(projects).set({ siteAddress: "Neue Adresse", workStage: "LPH 5", projectLead: null, projectEditor: null }).where(eq(projects.id, "project-1")).run();
|
||||
context.db.update(distributionBoards).set({ boardNumber: "UV-02", location: "OG", fedFrom: "HV", remark: null }).where(eq(distributionBoards.projectId, "project-1")).run();
|
||||
const changed = readProjectStateSnapshot(context.db, "project-1");
|
||||
assert.ok(changed);
|
||||
assert.notEqual(changed.payloadSha256, original.payloadSha256);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,23 @@ import {
|
|||
} from "../src/domain/models/project-state-snapshot.model.js";
|
||||
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
||||
|
||||
/** Removes the fields introduced by snapshot schema version 6. */
|
||||
function withoutVersionSixFields<
|
||||
T extends { project: object; distributionBoards: object[] },
|
||||
>(snapshot: T): T {
|
||||
const project = snapshot.project as Record<string, unknown>;
|
||||
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
|
||||
delete project[field];
|
||||
}
|
||||
for (const board of snapshot.distributionBoards) {
|
||||
const record = board as Record<string, unknown>;
|
||||
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
|
||||
delete record[field];
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function minimalSnapshot() {
|
||||
return {
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
|
|
@ -29,6 +46,10 @@ function minimalSnapshot() {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: "Musterstraße 1\n12345 Musterstadt" as string | null,
|
||||
workStage: "LPH 5" as string | null,
|
||||
projectLead: "P. Leitung" as string | null,
|
||||
projectEditor: null as string | null,
|
||||
},
|
||||
distributionBoards: [],
|
||||
externalCsvConfiguration: null,
|
||||
|
|
@ -61,6 +82,10 @@ function protectedSnapshot() {
|
|||
floorId: null,
|
||||
supplyType: null,
|
||||
simultaneityFactor: 1,
|
||||
boardNumber: "UV 1.1" as string | null,
|
||||
location: "Raum 003 Technik" as string | null,
|
||||
fedFrom: "HV 0.1" as string | null,
|
||||
remark: null as string | null,
|
||||
},
|
||||
],
|
||||
circuitLists: [
|
||||
|
|
@ -151,7 +176,7 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
|
||||
it("upgrades the supported baseline snapshot with a safe building default", () => {
|
||||
const baseline = structuredClone(minimalSnapshot());
|
||||
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
||||
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
||||
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
||||
|
|
@ -165,7 +190,7 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
|
||||
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
|
||||
const previous = structuredClone(minimalSnapshot());
|
||||
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||
previous.schemaVersion = 2 as typeof previous.schemaVersion;
|
||||
delete (previous as Record<string, unknown>).externalCsvConfiguration;
|
||||
delete (previous as Record<string, unknown>).externalModel;
|
||||
|
|
@ -183,7 +208,7 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
|
||||
it("upgrades snapshot version 3 with an empty external model", () => {
|
||||
const previous = structuredClone(minimalSnapshot());
|
||||
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||
previous.schemaVersion = 3 as typeof previous.schemaVersion;
|
||||
delete (previous as Record<string, unknown>).externalModel;
|
||||
|
||||
|
|
@ -199,8 +224,52 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
|
||||
const previous = withoutVersionSixFields(protectedSnapshot());
|
||||
previous.schemaVersion = 5 as typeof previous.schemaVersion;
|
||||
|
||||
const upgraded = parseProjectStateSnapshot(previous);
|
||||
|
||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||
assert.deepEqual(
|
||||
{
|
||||
siteAddress: upgraded.project.siteAddress,
|
||||
workStage: upgraded.project.workStage,
|
||||
projectLead: upgraded.project.projectLead,
|
||||
projectEditor: upgraded.project.projectEditor,
|
||||
},
|
||||
{ siteAddress: null, workStage: null, projectLead: null, projectEditor: null }
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
boardNumber: upgraded.distributionBoards[0]?.boardNumber,
|
||||
location: upgraded.distributionBoards[0]?.location,
|
||||
fedFrom: upgraded.distributionBoards[0]?.fedFrom,
|
||||
remark: upgraded.distributionBoards[0]?.remark,
|
||||
},
|
||||
{ boardNumber: null, location: null, fedFrom: null, remark: null }
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps board info and cover values and rejects them in older versions", () => {
|
||||
const parsed = parseProjectStateSnapshot(protectedSnapshot());
|
||||
assert.equal(parsed.distributionBoards[0]?.boardNumber, "UV 1.1");
|
||||
assert.equal(parsed.distributionBoards[0]?.location, "Raum 003 Technik");
|
||||
assert.equal(parsed.distributionBoards[0]?.fedFrom, "HV 0.1");
|
||||
assert.equal(parsed.project.siteAddress, "Musterstraße 1\n12345 Musterstadt");
|
||||
assert.equal(parsed.project.workStage, "LPH 5");
|
||||
|
||||
const mislabeled = protectedSnapshot();
|
||||
mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion;
|
||||
assert.throws(() => parseProjectStateSnapshot(mislabeled));
|
||||
|
||||
const missingCover = structuredClone(protectedSnapshot());
|
||||
delete (missingCover.project as Record<string, unknown>).workStage;
|
||||
assert.throws(() => parseProjectStateSnapshot(missingCover));
|
||||
});
|
||||
|
||||
it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
|
||||
const previous = protectedSnapshot();
|
||||
const previous = withoutVersionSixFields(protectedSnapshot());
|
||||
previous.schemaVersion = 4 as typeof previous.schemaVersion;
|
||||
previous.circuits[0]!.isReserve = false;
|
||||
previous.circuits[0]!.deviceRows = [{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue