Integrate rough estimate balance
This commit is contained in:
commit
573487666c
45 changed files with 9378 additions and 58 deletions
432
tests/estimate-balance-frontend.test.ts
Normal file
432
tests/estimate-balance-frontend.test.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { EstimateBalanceSnapshot } from "../src/shared/estimate-balance/estimate-balance.js";
|
||||
import {
|
||||
addEstimateGroup,
|
||||
addEstimateRow,
|
||||
createCopyName,
|
||||
createEstimateRow,
|
||||
estimateBalanceFingerprint,
|
||||
moveEstimateGroup,
|
||||
moveEstimateRow,
|
||||
normalizeEstimateSortOrder,
|
||||
removeEstimateGroup,
|
||||
removeEstimateRow,
|
||||
toSaveableEstimateBalance,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
validateEstimateBalance,
|
||||
} from "../src/frontend/estimate-balance/estimate-balance-document.js";
|
||||
import {
|
||||
estimateNumberRanges,
|
||||
formatAmpere,
|
||||
formatDecimalInput,
|
||||
formatDeltaPercent,
|
||||
formatIsoDateGerman,
|
||||
formatKw,
|
||||
formatSignedKw,
|
||||
getTodayIsoDate,
|
||||
parseGermanDecimal,
|
||||
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
|
||||
import {
|
||||
ApiRequestError,
|
||||
createEstimateBalance,
|
||||
deleteEstimateBalance,
|
||||
getEstimateBalance,
|
||||
getEstimateComparison,
|
||||
isRevisionConflictError,
|
||||
listEstimateBalances,
|
||||
updateEstimateBalance,
|
||||
} from "../src/frontend/utils/api.js";
|
||||
|
||||
function sequentialIds(prefix = "id") {
|
||||
let next = 0;
|
||||
return () => `${prefix}-${++next}`;
|
||||
}
|
||||
|
||||
function sampleBalance(): EstimateBalanceSnapshot {
|
||||
return {
|
||||
id: "balance-1",
|
||||
projectId: "project-1",
|
||||
name: "Entwurfsplanung",
|
||||
workStage: "LPH 3",
|
||||
dateIso: "2026-09-26",
|
||||
reserveAvPercent: 20,
|
||||
reserveSvPercent: 10,
|
||||
sortOrder: 0,
|
||||
groups: [
|
||||
{
|
||||
id: "group-410",
|
||||
costGroup: "410",
|
||||
title: "Sanitärtechnik",
|
||||
editor: null,
|
||||
sortOrder: 0,
|
||||
rows: [
|
||||
{ ...createEstimateRow(0, () => "row-a"), name: "Hebeanlage", powerPerUnitW: 1200 },
|
||||
{ ...createEstimateRow(1, () => "row-b"), name: "Durchlauferhitzer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "group-440",
|
||||
costGroup: "440",
|
||||
title: "Starkstromanlagen",
|
||||
editor: "ELT",
|
||||
sortOrder: 1,
|
||||
rows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("estimate balance number input", () => {
|
||||
it("accepts a comma as decimal separator", () => {
|
||||
assert.deepEqual(parseGermanDecimal("0,8"), { ok: true, value: 0.8 });
|
||||
assert.deepEqual(parseGermanDecimal(" 1200 "), { ok: true, value: 1200 });
|
||||
assert.deepEqual(parseGermanDecimal(",5"), { ok: true, value: 0.5 });
|
||||
assert.deepEqual(parseGermanDecimal("3,"), { ok: true, value: 3 });
|
||||
});
|
||||
|
||||
it("rejects dots, empty input and garbage", () => {
|
||||
for (const text of ["0.8", "1.200", "", " ", "abc", "1,2,3", "1e3", "--1"]) {
|
||||
assert.equal(parseGermanDecimal(text).ok, false, text);
|
||||
}
|
||||
const dot = parseGermanDecimal("0.8");
|
||||
assert.equal(dot.ok === false && dot.error.includes("Komma"), true);
|
||||
});
|
||||
|
||||
it("checks the server ranges of the document fields", () => {
|
||||
assert.equal(parseGermanDecimal("-1", estimateNumberRanges.quantity).ok, false);
|
||||
assert.equal(parseGermanDecimal("0", estimateNumberRanges.quantity).ok, true);
|
||||
assert.equal(parseGermanDecimal("1,1", estimateNumberRanges.simultaneityFactor).ok, false);
|
||||
assert.equal(parseGermanDecimal("1", estimateNumberRanges.simultaneityFactor).ok, true);
|
||||
assert.equal(parseGermanDecimal("0", estimateNumberRanges.cosPhi).ok, false);
|
||||
assert.equal(parseGermanDecimal("0,01", estimateNumberRanges.cosPhi).ok, true);
|
||||
assert.equal(parseGermanDecimal("101", estimateNumberRanges.reservePercent).ok, false);
|
||||
});
|
||||
|
||||
it("formats input text so it parses back unchanged", () => {
|
||||
assert.equal(formatDecimalInput(1200), "1200");
|
||||
assert.equal(formatDecimalInput(0.8), "0,8");
|
||||
const parsed = parseGermanDecimal(formatDecimalInput(12345.678));
|
||||
assert.deepEqual(parsed, { ok: true, value: 12345.678 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance display format", () => {
|
||||
it("rounds half away from zero like Excel", () => {
|
||||
assert.equal(formatKw(40.175), "40,18");
|
||||
assert.equal(formatKw(30.24), "30,24");
|
||||
assert.equal(formatKw(1234.5), "1.234,50");
|
||||
assert.equal(formatKw(-0.001), "0,00");
|
||||
assert.equal(formatAmpere(57.75), "57,75");
|
||||
assert.equal(formatAmpere(235.749), "235,75");
|
||||
});
|
||||
|
||||
it("shows signed deltas and percentages", () => {
|
||||
assert.equal(formatSignedKw(1.5), "+1,50");
|
||||
assert.equal(formatSignedKw(-1.825), "-1,83");
|
||||
assert.equal(formatSignedKw(0.001), "0,00");
|
||||
assert.equal(formatDeltaPercent(12.34), "+12,3 %");
|
||||
assert.equal(formatDeltaPercent(-5), "-5,0 %");
|
||||
assert.equal(formatDeltaPercent(null), "–");
|
||||
});
|
||||
|
||||
it("formats dates", () => {
|
||||
assert.equal(formatIsoDateGerman("2026-09-26"), "26.09.2026");
|
||||
assert.equal(formatIsoDateGerman(null), "–");
|
||||
assert.equal(getTodayIsoDate(new Date(2026, 0, 5, 23, 30)), "2026-01-05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance document editing", () => {
|
||||
it("adds rows at the end or after a row with continuous sort orders", () => {
|
||||
const createId = sequentialIds("new");
|
||||
const appended = addEstimateRow(sampleBalance(), "group-410", { createId });
|
||||
assert.equal(appended.rowId, "new-1");
|
||||
assert.deepEqual(
|
||||
appended.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-a", 0], ["row-b", 1], ["new-1", 2]]
|
||||
);
|
||||
const inserted = addEstimateRow(sampleBalance(), "group-410", {
|
||||
afterRowId: "row-a",
|
||||
createId,
|
||||
});
|
||||
assert.deepEqual(
|
||||
inserted.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-a", 0], ["new-2", 1], ["row-b", 2]]
|
||||
);
|
||||
const unknownGroup = addEstimateRow(sampleBalance(), "missing", { createId });
|
||||
assert.equal(unknownGroup.rowId, null);
|
||||
});
|
||||
|
||||
it("creates rows with neutral defaults", () => {
|
||||
const row = createEstimateRow(3, () => "row-x");
|
||||
assert.deepEqual(row, {
|
||||
id: "row-x",
|
||||
sortOrder: 3,
|
||||
supply: null,
|
||||
name: "",
|
||||
quantity: 1,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates, removes and moves rows without touching the input", () => {
|
||||
const original = sampleBalance();
|
||||
const updated = updateEstimateRow(original, "group-410", "row-b", {
|
||||
quantity: 3,
|
||||
safetySupply: true,
|
||||
});
|
||||
assert.equal(updated.groups[0]!.rows[1]!.quantity, 3);
|
||||
assert.equal(updated.groups[0]!.rows[1]!.safetySupply, true);
|
||||
assert.equal(original.groups[0]!.rows[1]!.quantity, 1);
|
||||
|
||||
const removed = removeEstimateRow(original, "group-410", "row-a");
|
||||
assert.deepEqual(
|
||||
removed.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-b", 0]]
|
||||
);
|
||||
|
||||
const moved = moveEstimateRow(original, "group-410", "row-b", -1);
|
||||
assert.deepEqual(
|
||||
moved.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-b", 0], ["row-a", 1]]
|
||||
);
|
||||
assert.equal(moveEstimateRow(original, "group-410", "row-a", -1), original);
|
||||
});
|
||||
|
||||
it("adds, edits, moves and removes groups", () => {
|
||||
const added = addEstimateGroup(
|
||||
sampleBalance(),
|
||||
{ costGroup: "480", title: "Gebäudeautomation" },
|
||||
() => "group-480"
|
||||
);
|
||||
assert.equal(added.groupId, "group-480");
|
||||
assert.deepEqual(
|
||||
added.balance.groups.map((group) => [group.id, group.sortOrder]),
|
||||
[["group-410", 0], ["group-440", 1], ["group-480", 2]]
|
||||
);
|
||||
assert.equal(added.balance.groups[2]!.editor, null);
|
||||
|
||||
const edited = updateEstimateGroup(added.balance, "group-480", {
|
||||
costGroup: "481",
|
||||
editor: "MSR",
|
||||
});
|
||||
assert.equal(edited.groups[2]!.costGroup, "481");
|
||||
assert.equal(edited.groups[2]!.title, "Gebäudeautomation");
|
||||
|
||||
const movedUp = moveEstimateGroup(edited, "group-480", -1);
|
||||
assert.deepEqual(
|
||||
movedUp.groups.map((group) => [group.id, group.sortOrder]),
|
||||
[["group-410", 0], ["group-480", 1], ["group-440", 2]]
|
||||
);
|
||||
assert.equal(moveEstimateGroup(movedUp, "group-440", 1), movedUp);
|
||||
|
||||
const removed = removeEstimateGroup(movedUp, "group-410");
|
||||
assert.deepEqual(
|
||||
removed.groups.map((group) => [group.id, group.sortOrder]),
|
||||
[["group-480", 0], ["group-440", 1]]
|
||||
);
|
||||
assert.equal(removeEstimateGroup(removed, "missing"), removed);
|
||||
});
|
||||
|
||||
it("orders loaded documents by sortOrder", () => {
|
||||
const balance = sampleBalance();
|
||||
const shuffled: EstimateBalanceSnapshot = {
|
||||
...balance,
|
||||
groups: [
|
||||
{ ...balance.groups[1]!, sortOrder: 7 },
|
||||
{
|
||||
...balance.groups[0]!,
|
||||
sortOrder: 2,
|
||||
rows: [
|
||||
{ ...balance.groups[0]!.rows[1]!, sortOrder: 5 },
|
||||
{ ...balance.groups[0]!.rows[0]!, sortOrder: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const normalized = normalizeEstimateSortOrder(shuffled);
|
||||
assert.deepEqual(
|
||||
normalized.groups.map((group) => [group.id, group.sortOrder]),
|
||||
[["group-410", 0], ["group-440", 1]]
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalized.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-a", 0], ["row-b", 1]]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance saving", () => {
|
||||
it("builds the trimmed server document with null for empty optional texts", () => {
|
||||
let balance = sampleBalance();
|
||||
balance = { ...balance, name: " Entwurf ", workStage: " ", dateIso: "" };
|
||||
balance = updateEstimateGroup(balance, "group-440", { title: " Starkstrom ", editor: " " });
|
||||
balance = updateEstimateRow(balance, "group-410", "row-a", {
|
||||
name: " Hebeanlage ",
|
||||
supply: " ELT ",
|
||||
remark: " ",
|
||||
});
|
||||
const saveable = toSaveableEstimateBalance(balance);
|
||||
assert.equal(saveable.name, "Entwurf");
|
||||
assert.equal(saveable.workStage, null);
|
||||
assert.equal(saveable.dateIso, null);
|
||||
assert.equal(saveable.groups[1]!.title, "Starkstrom");
|
||||
assert.equal(saveable.groups[1]!.editor, null);
|
||||
assert.deepEqual(
|
||||
{
|
||||
name: saveable.groups[0]!.rows[0]!.name,
|
||||
supply: saveable.groups[0]!.rows[0]!.supply,
|
||||
remark: saveable.groups[0]!.rows[0]!.remark,
|
||||
},
|
||||
{ name: "Hebeanlage", supply: "ELT", remark: null }
|
||||
);
|
||||
});
|
||||
|
||||
it("copies only snapshot fields", () => {
|
||||
const balance = sampleBalance() as EstimateBalanceSnapshot & { uiState?: string };
|
||||
balance.uiState = "leak";
|
||||
assert.equal("uiState" in toSaveableEstimateBalance(balance), false);
|
||||
});
|
||||
|
||||
it("treats whitespace-only changes as unchanged", () => {
|
||||
const balance = sampleBalance();
|
||||
const withSpace = updateEstimateRow(balance, "group-410", "row-a", {
|
||||
name: "Hebeanlage ",
|
||||
});
|
||||
assert.equal(estimateBalanceFingerprint(withSpace), estimateBalanceFingerprint(balance));
|
||||
const changed = updateEstimateRow(balance, "group-410", "row-a", { quantity: 2 });
|
||||
assert.notEqual(estimateBalanceFingerprint(changed), estimateBalanceFingerprint(balance));
|
||||
});
|
||||
|
||||
it("reports documents the server would reject", () => {
|
||||
assert.deepEqual(validateEstimateBalance(sampleBalance()), []);
|
||||
const invalid = updateEstimateGroup(
|
||||
{ ...sampleBalance(), name: " " },
|
||||
"group-440",
|
||||
{ title: " " }
|
||||
);
|
||||
assert.equal(validateEstimateBalance(invalid).length, 2);
|
||||
});
|
||||
|
||||
it("names copies", () => {
|
||||
assert.equal(createCopyName("Entwurfsplanung "), "Entwurfsplanung (Kopie)");
|
||||
assert.equal(createCopyName("x".repeat(200)).length, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance frontend API", () => {
|
||||
it("calls the estimate balance endpoints", async () => {
|
||||
const requests: Array<{ url: string; method: string; body: unknown; keepalive?: boolean }> =
|
||||
[];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||||
...(init?.keepalive ? { keepalive: true } : {}),
|
||||
});
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const balance = sampleBalance();
|
||||
await listEstimateBalances("project-1");
|
||||
await getEstimateBalance("project-1", "balance-1");
|
||||
await createEstimateBalance(
|
||||
"project-1",
|
||||
{ name: "Vorplanung", workStage: null, dateIso: "2026-09-26", template: "kg400" },
|
||||
4
|
||||
);
|
||||
await createEstimateBalance(
|
||||
"project-1",
|
||||
{ name: "Kopie", template: "copy", copyFromBalanceId: "balance-1" },
|
||||
5
|
||||
);
|
||||
await updateEstimateBalance("project-1", balance, 6);
|
||||
await updateEstimateBalance("project-1", balance, 7, { keepalive: true });
|
||||
await deleteEstimateBalance("project-1", "balance-1", 8);
|
||||
await getEstimateComparison("project-1", "balance-1");
|
||||
const base = "/api/projects/project-1/estimate-balances";
|
||||
assert.deepEqual(requests, [
|
||||
{ url: base, method: "GET", body: undefined },
|
||||
{ url: `${base}/balance-1`, method: "GET", body: undefined },
|
||||
{
|
||||
url: base,
|
||||
method: "POST",
|
||||
body: {
|
||||
name: "Vorplanung",
|
||||
workStage: null,
|
||||
dateIso: "2026-09-26",
|
||||
template: "kg400",
|
||||
expectedRevision: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: base,
|
||||
method: "POST",
|
||||
body: {
|
||||
name: "Kopie",
|
||||
template: "copy",
|
||||
copyFromBalanceId: "balance-1",
|
||||
expectedRevision: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${base}/balance-1`,
|
||||
method: "PUT",
|
||||
body: { expectedRevision: 6, balance },
|
||||
},
|
||||
{
|
||||
url: `${base}/balance-1`,
|
||||
method: "PUT",
|
||||
body: { expectedRevision: 7, balance },
|
||||
keepalive: true,
|
||||
},
|
||||
{ url: `${base}/balance-1`, method: "DELETE", body: { expectedRevision: 8 } },
|
||||
{ url: `${base}/balance-1/comparison`, method: "GET", body: undefined },
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes status and code of failed requests", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "Projektstand veraltet.",
|
||||
code: "PROJECT_REVISION_CONFLICT",
|
||||
}),
|
||||
{ status: 409, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
try {
|
||||
await assert.rejects(
|
||||
updateEstimateBalance("project-1", sampleBalance(), 1),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiRequestError);
|
||||
assert.equal(error.message, "Projektstand veraltet.");
|
||||
assert.equal(error.status, 409);
|
||||
assert.equal(error.code, "PROJECT_REVISION_CONFLICT");
|
||||
assert.equal(isRevisionConflictError(error), true);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.equal(isRevisionConflictError(new ApiRequestError("x", 400)), false);
|
||||
assert.equal(isRevisionConflictError(new Error("x")), false);
|
||||
});
|
||||
});
|
||||
458
tests/estimate-balance.test.ts
Normal file
458
tests/estimate-balance.test.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
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,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js";
|
||||
import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||
import { estimateRows } from "../src/db/schema/estimate-balances.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createEstimateBalanceDeleteProjectCommand,
|
||||
createEstimateBalanceInsertProjectCommand,
|
||||
createEstimateBalanceSnapshot,
|
||||
createEstimateBalanceUpdateProjectCommand,
|
||||
parseEstimateBalanceSnapshot,
|
||||
type EstimateBalanceProjectCommand,
|
||||
} from "../src/estimate-balance/domain/estimate-balance-command.model.js";
|
||||
import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js";
|
||||
import {
|
||||
calculateEstimateBalance,
|
||||
calculateEstimateRow,
|
||||
compareEstimateWithDetailed,
|
||||
estimateDefaultGroups,
|
||||
matchEstimateCostGroup,
|
||||
roundEstimateValue,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateRowSnapshot,
|
||||
} from "../src/shared/estimate-balance/estimate-balance.js";
|
||||
|
||||
const round2 = (value: number) => roundEstimateValue(value, 2);
|
||||
let nextId = 0;
|
||||
const id = () => `id-${++nextId}`;
|
||||
|
||||
function row(
|
||||
name: string,
|
||||
quantity: number,
|
||||
powerPerUnitW: number,
|
||||
simultaneityFactor: number,
|
||||
cosPhi: number,
|
||||
extra: Partial<EstimateRowSnapshot> = {}
|
||||
): EstimateRowSnapshot {
|
||||
return {
|
||||
id: id(),
|
||||
sortOrder: 0,
|
||||
supply: "ELT",
|
||||
name,
|
||||
quantity,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW,
|
||||
simultaneityFactor,
|
||||
cosPhi,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** All rows of the office example "Leistungsbilanz - KG400" (Entwurfsplanung). */
|
||||
function templateExample(): EstimateBalanceSnapshot {
|
||||
const rowsByGroup: Record<string, EstimateRowSnapshot[]> = {
|
||||
"410": [
|
||||
row("Durchlauferhitzer 11kW", 9, 11200, 0.3, 1),
|
||||
row("Durchlauferhitzer 4,4kW", 4, 4400, 0.3, 1),
|
||||
],
|
||||
"420": [
|
||||
row("Fernwärmestation", 1, 1000, 0.8, 1),
|
||||
row("Pumpen", 4, 200, 0.8, 0.9),
|
||||
row("Schaltschrank Heizung", 1, 2000, 0.8, 1),
|
||||
],
|
||||
"430": [
|
||||
row("Lüftungsgerät", 1, 6000, 0.8, 0.9),
|
||||
row("Schaltschrank Lüftung", 1, 2000, 0.8, 0.9),
|
||||
],
|
||||
"434": [
|
||||
row("Kälteaggregat Dach", 1, 1000, 0.8, 1),
|
||||
row("Serverraum Kühlung Aggregat Dach", 1, 2000, 0.8, 1),
|
||||
row("Serverraum Innengerät", 1, 1000, 0.8, 1),
|
||||
],
|
||||
"440": [
|
||||
row("Beleuchtungsanlage Klassenzimmer", 17, 500, 0.8, 0.95),
|
||||
row("Beleuchtung Allgemein", 50, 45, 0.7, 0.95),
|
||||
row("Außenbeleuchtung", 10, 100, 0.4, 0.95),
|
||||
row("Fassadenbeleuchtung", 20, 60, 0.5, 0.95),
|
||||
row("Beleuchtung MFR", 15, 50, 0.8, 0.95),
|
||||
row("Steckdosen Klassenzimmer", 170, 300, 0.2, 0.95),
|
||||
row("Steckdosen 230 V allg. Verbr.", 100, 500, 0.2, 0.95),
|
||||
row("Veranstaltungstechnik", 1, 4000, 0.5, 0.95),
|
||||
row("Küchentechnische Anlagen", 1, 10000, 0.4, 0.8),
|
||||
row("Sicherheitsbeleuchtung Zentrale", 1, 2000, 0.8, 0.9),
|
||||
row("Spezielle Experimentieranlagen", 1, 6000, 0.4, 0.9),
|
||||
],
|
||||
"450": [row("EDV Systeme", 5, 3000, 0.8, 0.95)],
|
||||
"460": [row("Aufzug", 1, 16000, 0.7, 0.95)],
|
||||
"470": [row("E-Mobility Vorbereitung", 9, 11000, 0.3, 1)],
|
||||
"480": [],
|
||||
"": [row("Allgemein", 1, 2000, 1, 0.95)],
|
||||
};
|
||||
return {
|
||||
id: "balance-1",
|
||||
projectId: "project-1",
|
||||
name: "Entwurfsplanung",
|
||||
workStage: "LPH 3",
|
||||
dateIso: "2023-06-30",
|
||||
reserveAvPercent: 10,
|
||||
reserveSvPercent: 0,
|
||||
sortOrder: 0,
|
||||
groups: estimateDefaultGroups.map((group, index) => ({
|
||||
id: `group-${group.costGroup || "allg"}`,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: null,
|
||||
sortOrder: index,
|
||||
rows: rowsByGroup[group.costGroup] ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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: "Foreign project" },
|
||||
])
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
describe("estimate balance calculation", () => {
|
||||
it("reproduces the office template totals", () => {
|
||||
const result = calculateEstimateBalance(templateExample(), 400);
|
||||
const groupTotals = result.groups.map((group) => [
|
||||
round2(group.av.kw),
|
||||
round2(group.av.kva),
|
||||
]);
|
||||
assert.deepEqual(groupTotals, [
|
||||
[35.52, 35.52],
|
||||
[3.04, 3.11],
|
||||
[6.4, 7.11],
|
||||
[3.2, 3.2],
|
||||
[40.18, 43.31],
|
||||
[12, 12.63],
|
||||
[11.2, 11.79],
|
||||
[29.7, 29.7],
|
||||
[0, 0],
|
||||
[2, 2.11],
|
||||
]);
|
||||
assert.equal(round2(result.total.av.kw), 143.24);
|
||||
assert.equal(round2(result.total.av.kva), 148.48);
|
||||
assert.equal(round2(result.reserve.av.kw), 14.32);
|
||||
assert.equal(round2(result.reserve.av.kva), 14.85);
|
||||
assert.equal(round2(result.withReserve.av.kw), 157.56);
|
||||
assert.equal(round2(result.withReserve.av.kva), 163.33);
|
||||
assert.equal(round2(result.maxCurrentA.av), 235.75);
|
||||
assert.equal(result.total.sv.kw, 0);
|
||||
assert.equal(result.maxCurrentA.sv, 0);
|
||||
});
|
||||
|
||||
it("counts safety-supply rows in AV and additionally in SV, also per m²", () => {
|
||||
const area = calculateEstimateRow({
|
||||
quantity: 250,
|
||||
powerPerUnitW: 12,
|
||||
simultaneityFactor: 0.8,
|
||||
cosPhi: 0.95,
|
||||
safetySupply: true,
|
||||
});
|
||||
assert.equal(round2(area.av.kw), 2.4);
|
||||
assert.equal(round2(area.av.kva), 2.53);
|
||||
assert.deepEqual(area.sv, area.av);
|
||||
const balance = templateExample();
|
||||
balance.reserveSvPercent = 20;
|
||||
balance.groups[4]!.rows[9]!.safetySupply = true; // Sicherheitsbeleuchtung Zentrale
|
||||
const result = calculateEstimateBalance(balance, 400);
|
||||
assert.equal(round2(result.total.sv.kw), 1.6);
|
||||
assert.equal(round2(result.withReserve.sv.kw), 1.92);
|
||||
assert.equal(round2(result.total.av.kw), 143.24);
|
||||
});
|
||||
|
||||
it("rounds displayed values like the spreadsheet", () => {
|
||||
assert.equal(roundEstimateValue(40.175), 40.18);
|
||||
assert.equal(roundEstimateValue(143.235), 143.24);
|
||||
assert.equal(roundEstimateValue(-1.825), -1.83);
|
||||
assert.equal(roundEstimateValue(0.711111), 0.71);
|
||||
assert.equal(roundEstimateValue(235.749, 1), 235.7);
|
||||
});
|
||||
|
||||
it("matches detailed cost groups to the most specific estimate group", () => {
|
||||
const codes = ["410", "430", "434", "440", "480", ""];
|
||||
assert.equal(matchEstimateCostGroup("444", codes), "440");
|
||||
assert.equal(matchEstimateCostGroup("434", codes), "434");
|
||||
assert.equal(matchEstimateCostGroup("431", codes), "430");
|
||||
assert.equal(matchEstimateCostGroup("KG 481.1", codes), "480");
|
||||
assert.equal(matchEstimateCostGroup("4", codes), null);
|
||||
assert.equal(matchEstimateCostGroup("", codes), null);
|
||||
assert.equal(matchEstimateCostGroup(null, codes), null);
|
||||
assert.equal(matchEstimateCostGroup("450", codes), null);
|
||||
});
|
||||
|
||||
it("compares the estimate with detailed device rows per cost group", () => {
|
||||
const comparison = compareEstimateWithDetailed(templateExample(), [
|
||||
{ costGroup: "444", kw: 30, cosPhi: 0.9 },
|
||||
{ costGroup: "445", kw: 12, cosPhi: null },
|
||||
{ costGroup: "412", kw: 40, cosPhi: 1 },
|
||||
{ costGroup: null, kw: 5, cosPhi: 1 },
|
||||
{ costGroup: "399", kw: 1, cosPhi: 1 },
|
||||
]);
|
||||
const kg440 = comparison.groups.find((group) => group.costGroup === "440")!;
|
||||
assert.equal(round2(kg440.detailed.kw), 42);
|
||||
assert.equal(round2(kg440.detailed.kva), 45.33);
|
||||
assert.equal(kg440.detailedRowCount, 2);
|
||||
assert.ok(Math.abs(kg440.deltaKw - 1.825) < 1e-9);
|
||||
assert.equal(round2(kg440.deltaPercent!), 4.54);
|
||||
const kg410 = comparison.groups.find((group) => group.costGroup === "410")!;
|
||||
assert.equal(round2(kg410.deltaKw), 4.48);
|
||||
const kg480 = comparison.groups.find((group) => group.costGroup === "480")!;
|
||||
assert.equal(kg480.deltaPercent, null);
|
||||
assert.deepEqual(comparison.unassigned, { kw: 6, kva: 6, rowCount: 2 });
|
||||
assert.equal(round2(comparison.total.detailed.kw), 88);
|
||||
assert.equal(round2(comparison.total.estimatedAv.kw), 143.24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance snapshots and commands", () => {
|
||||
it("validates snapshots strictly", () => {
|
||||
const valid = templateExample();
|
||||
assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3);
|
||||
const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [
|
||||
(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0),
|
||||
(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5),
|
||||
(balance) => (balance.groups[0]!.rows[0]!.quantity = -1),
|
||||
(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "),
|
||||
(balance) => (balance.groups[0]!.rows[0]!.supply = ""),
|
||||
(balance) => (balance.groups[0]!.title = ""),
|
||||
(balance) => (balance.dateIso = "30.06.2023"),
|
||||
(balance) => (balance.reserveAvPercent = 101),
|
||||
(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id),
|
||||
(balance) => ((balance.groups[0]!.rows[0] as unknown as Record<string, unknown>).extra = 1),
|
||||
];
|
||||
for (const mutate of invalid) {
|
||||
const balance = structuredClone(valid);
|
||||
mutate(balance);
|
||||
assert.throws(() => parseEstimateBalanceSnapshot(balance));
|
||||
}
|
||||
assert.equal(
|
||||
createEstimateBalanceRequestSchema.safeParse({
|
||||
expectedRevision: 0,
|
||||
name: "Entwurf",
|
||||
template: "copy",
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the KG 400 template and copies a Stand with new ids", () => {
|
||||
const created = createEstimateBalanceSnapshot({
|
||||
projectId: "project-1",
|
||||
name: " Vorplanung ",
|
||||
workStage: "LPH 2",
|
||||
dateIso: "2026-09-26",
|
||||
sortOrder: 0,
|
||||
template: "kg400",
|
||||
});
|
||||
assert.equal(created.name, "Vorplanung");
|
||||
assert.deepEqual(
|
||||
created.groups.map((group) => group.costGroup),
|
||||
estimateDefaultGroups.map((group) => group.costGroup)
|
||||
);
|
||||
assert.equal(created.reserveAvPercent, 10);
|
||||
const source = templateExample();
|
||||
const copy = createEstimateBalanceSnapshot({
|
||||
projectId: "project-1",
|
||||
name: "Entwurfsplanung Kopie",
|
||||
workStage: null,
|
||||
dateIso: null,
|
||||
sortOrder: 1,
|
||||
template: { copyFrom: source },
|
||||
});
|
||||
assert.notEqual(copy.id, source.id);
|
||||
assert.notEqual(copy.groups[0]!.id, source.groups[0]!.id);
|
||||
assert.notEqual(copy.groups[0]!.rows[0]!.id, source.groups[0]!.rows[0]!.id);
|
||||
assert.equal(
|
||||
round2(calculateEstimateBalance(copy, 400).total.av.kw),
|
||||
143.24
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts, updates and deletes balances with persisted undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const store = new EstimateBalanceProjectCommandRepository(context.db);
|
||||
const reader = new EstimateBalanceRepository(context.db);
|
||||
const history = new ProjectHistoryRepository(context.db);
|
||||
let revision = 0;
|
||||
const run = (
|
||||
command: EstimateBalanceProjectCommand,
|
||||
source: "user" | "undo" | "redo" = "user"
|
||||
) => {
|
||||
const step = source === "user" ? null : history.getNextCommand("project-1", source);
|
||||
const result = store.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: revision,
|
||||
source,
|
||||
historyTargetChangeSetId: step?.changeSetId,
|
||||
command,
|
||||
});
|
||||
revision += 1;
|
||||
return result;
|
||||
};
|
||||
|
||||
const balance = templateExample();
|
||||
const insert = createEstimateBalanceInsertProjectCommand(balance);
|
||||
const inserted = run(insert);
|
||||
const stored = reader.findById("project-1", balance.id);
|
||||
assert.deepEqual(stored, parseEstimateBalanceSnapshot(balance));
|
||||
assert.equal(reader.listByProject("project-2").length, 0);
|
||||
|
||||
const target = structuredClone(stored!);
|
||||
target.groups[0]!.rows.splice(1, 1);
|
||||
target.groups[0]!.rows[0]!.quantity = 10;
|
||||
target.groups[8]!.rows.push(row("GLT-Schrank", 2, 500, 1, 0.95));
|
||||
target.groups[8]!.editor = "MSR";
|
||||
target.reserveAvPercent = 15;
|
||||
const update = createEstimateBalanceUpdateProjectCommand(stored!, target);
|
||||
const updated = run(update);
|
||||
assert.deepEqual(
|
||||
reader.findById("project-1", balance.id),
|
||||
parseEstimateBalanceSnapshot(target)
|
||||
);
|
||||
|
||||
run(updated.inverse, "undo");
|
||||
assert.deepEqual(reader.findById("project-1", balance.id), stored);
|
||||
run(update, "redo");
|
||||
assert.deepEqual(
|
||||
reader.findById("project-1", balance.id),
|
||||
parseEstimateBalanceSnapshot(target)
|
||||
);
|
||||
|
||||
// A stale expected document is rejected.
|
||||
assert.throws(
|
||||
() => run(createEstimateBalanceUpdateProjectCommand(stored!, { ...stored!, name: "X" })),
|
||||
/changed before update/
|
||||
);
|
||||
assert.throws(
|
||||
() => createEstimateBalanceUpdateProjectCommand(stored!, structuredClone(stored!)),
|
||||
/must change/
|
||||
);
|
||||
|
||||
const current = reader.findById("project-1", balance.id)!;
|
||||
const deleted = run(createEstimateBalanceDeleteProjectCommand(current));
|
||||
assert.equal(reader.findById("project-1", balance.id), null);
|
||||
assert.equal(context.db.select().from(estimateRows).all().length, 0);
|
||||
run(deleted.inverse, "undo");
|
||||
assert.deepEqual(reader.findById("project-1", balance.id), current);
|
||||
// The original insert's inverse no longer matches the edited balance.
|
||||
assert.throws(() => run(inserted.inverse), /changed before deletion/);
|
||||
run(createEstimateBalanceDeleteProjectCommand(current));
|
||||
assert.equal(reader.listByProject("project-1").length, 0);
|
||||
|
||||
assert.throws(
|
||||
() => run(createEstimateBalanceInsertProjectCommand({ ...balance, projectId: "project-2" })),
|
||||
/does not belong to project/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("sums detailed device rows of all circuit lists of the project", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db
|
||||
.insert(distributionBoards)
|
||||
.values({ id: "board-1", projectId: "project-1", name: "UV 1" })
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitLists)
|
||||
.values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1" })
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitSections)
|
||||
.values({
|
||||
id: "section-1",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting-1",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "1F1",
|
||||
sortOrder: 0,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "section-1",
|
||||
equipmentIdentifier: "-1F1",
|
||||
displayName: "Licht",
|
||||
sortOrder: 0,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitDeviceRows)
|
||||
.values([
|
||||
{
|
||||
id: "row-1",
|
||||
circuitId: "circuit-1",
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
costGroup: "445",
|
||||
quantity: 10,
|
||||
powerPerUnit: 0.05,
|
||||
simultaneityFactor: 0.8,
|
||||
cosPhi: 0.95,
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
circuitId: "circuit-1",
|
||||
name: "Steckdose",
|
||||
displayName: "Steckdose",
|
||||
costGroup: null,
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.2,
|
||||
simultaneityFactor: 0.5,
|
||||
cosPhi: null,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
const detailed = new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower(
|
||||
"project-1"
|
||||
);
|
||||
assert.deepEqual(
|
||||
detailed.map((entry) => ({ ...entry, kw: round2(entry.kw) })),
|
||||
[
|
||||
{ costGroup: "445", kw: 0.4, cosPhi: 0.95 },
|
||||
{ costGroup: null, kw: 0.2, cosPhi: null },
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower("project-2").length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -49,6 +49,9 @@ it("upgrades a populated pre-print database through the current schema without c
|
|||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
|
||||
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.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_balances").get()!.count, 0);
|
||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_groups").get()!.count, 0);
|
||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_rows").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 });
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ import {
|
|||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
||||
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
||||
import {
|
||||
deleteEstimateBalance,
|
||||
insertEstimateBalance,
|
||||
readEstimateBalances,
|
||||
} from "../src/db/repositories/estimate-balance.persistence.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
|
|
@ -28,8 +34,48 @@ import { projects } from "../src/db/schema/projects.js";
|
|||
import { rooms } from "../src/db/schema/rooms.js";
|
||||
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
|
||||
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
|
||||
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
|
||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js";
|
||||
import { parseEstimateBalanceSnapshot } from "../src/estimate-balance/domain/estimate-balance-command.model.js";
|
||||
|
||||
function estimateBalance(projectId: string, suffix = "") {
|
||||
return parseEstimateBalanceSnapshot({
|
||||
id: `estimate${suffix}`,
|
||||
projectId,
|
||||
name: `Vorplanung${suffix}`,
|
||||
workStage: "LPH 2",
|
||||
dateIso: "2026-09-27",
|
||||
reserveAvPercent: 10,
|
||||
reserveSvPercent: 5,
|
||||
sortOrder: 0,
|
||||
groups: [
|
||||
{
|
||||
id: `estimate-group${suffix}`,
|
||||
costGroup: "440",
|
||||
title: "Starkstromanlagen",
|
||||
editor: "Planung",
|
||||
sortOrder: 0,
|
||||
rows: [
|
||||
{
|
||||
id: `estimate-row${suffix}`,
|
||||
sortOrder: 0,
|
||||
supply: "ELT",
|
||||
name: "Beleuchtung",
|
||||
quantity: 100,
|
||||
unit: "area",
|
||||
connection: "fixed",
|
||||
powerPerUnitW: 12,
|
||||
simultaneityFactor: 0.8,
|
||||
cosPhi: 0.95,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
|
|
@ -463,14 +509,41 @@ describe("project snapshot repository", () => {
|
|||
});
|
||||
|
||||
describe("portable project transfer", () => {
|
||||
it("keeps estimate balances outside normal snapshot restore", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
insertEstimateBalance(context.db, estimateBalance("project-1"));
|
||||
const current = readProjectStateSnapshot(context.db, "project-1");
|
||||
assert.ok(current);
|
||||
new ProjectStateRestoreCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "restore",
|
||||
command: createProjectStateRestoreCommand(current.payloadSha256, {
|
||||
...current.state,
|
||||
project: { ...current.state.project, name: "Restored project" },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(
|
||||
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
|
||||
["estimate"]
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
insertEstimateBalance(context.db, estimateBalance("project-1"));
|
||||
const repository = new ProjectTransferRepository(context.db);
|
||||
const transfer = repository.exportProject("project-1");
|
||||
assert.ok(transfer);
|
||||
assert.equal(transfer.formatVersion, 2);
|
||||
assert.equal(transfer.estimateBalances.length, 1);
|
||||
const duplicate = repository.importDuplicate(transfer);
|
||||
assert.notEqual(duplicate.projectId, "project-1");
|
||||
assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)");
|
||||
|
|
@ -541,6 +614,18 @@ describe("portable project transfer", () => {
|
|||
copied.state.distributionBoards[0].simultaneityFactor,
|
||||
0.65
|
||||
);
|
||||
const copiedBalances = readEstimateBalances(context.db, duplicate.projectId);
|
||||
assert.equal(copiedBalances.length, 1);
|
||||
assert.notEqual(copiedBalances[0].id, transfer.estimateBalances[0].id);
|
||||
assert.notEqual(
|
||||
copiedBalances[0].groups[0].id,
|
||||
transfer.estimateBalances[0].groups[0].id
|
||||
);
|
||||
assert.notEqual(
|
||||
copiedBalances[0].groups[0].rows[0].id,
|
||||
transfer.estimateBalances[0].groups[0].rows[0].id
|
||||
);
|
||||
assert.equal(copiedBalances[0].groups[0].rows[0].name, "Beleuchtung");
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
|
|
@ -566,6 +651,14 @@ describe("portable project transfer", () => {
|
|||
}),
|
||||
/checksum verification failed/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.importDuplicate({
|
||||
...transfer,
|
||||
estimateBalances: [estimateBalance("project-1", "-tampered")],
|
||||
}),
|
||||
/checksum verification failed/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projects).all().length,
|
||||
1
|
||||
|
|
@ -575,15 +668,79 @@ describe("portable project transfer", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("imports legacy transfer version 1 with no estimate balances", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = readProjectStateSnapshot(context.db, "project-1");
|
||||
assert.ok(snapshot);
|
||||
const legacyTransfer = {
|
||||
format: "leistungsbilanz.project",
|
||||
formatVersion: 1,
|
||||
exportedAtIso: "2026-09-27T12:00:00.000Z",
|
||||
payloadSha256: snapshot.payloadSha256,
|
||||
projectState: snapshot.state,
|
||||
};
|
||||
const duplicate = new ProjectTransferRepository(context.db).importDuplicate(
|
||||
legacyTransfer
|
||||
);
|
||||
assert.deepEqual(
|
||||
readEstimateBalances(context.db, duplicate.projectId),
|
||||
[]
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces exported estimate balances atomically and restores them on undo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
insertEstimateBalance(context.db, estimateBalance("project-1"));
|
||||
const transferRepository = new ProjectTransferRepository(context.db);
|
||||
const transfer = transferRepository.exportProject("project-1");
|
||||
assert.ok(transfer);
|
||||
|
||||
deleteEstimateBalance(context.db, "project-1", "estimate");
|
||||
insertEstimateBalance(context.db, estimateBalance("project-1", "-changed"));
|
||||
const prepared = transferRepository.prepareReplace("project-1", transfer);
|
||||
assert.ok(prepared);
|
||||
const restored = new ProjectStateRestoreCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "restore",
|
||||
command: prepared.command,
|
||||
});
|
||||
assert.deepEqual(
|
||||
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
|
||||
["estimate"]
|
||||
);
|
||||
|
||||
new ProjectStateRestoreCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: restored.revision.changeSetId,
|
||||
command: restored.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
|
||||
["estimate-changed"]
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back a duplicate when a late project-state insert fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
insertEstimateBalance(context.db, estimateBalance("project-1"));
|
||||
const repository = new ProjectTransferRepository(context.db);
|
||||
const transfer = repository.exportProject("project-1");
|
||||
assert.ok(transfer);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_imported_device_row
|
||||
BEFORE INSERT ON circuit_device_rows
|
||||
CREATE TRIGGER fail_imported_estimate_row
|
||||
BEFORE INSERT ON estimate_rows
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced project transfer failure');
|
||||
END;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue