Add external row quantity foundation

This commit is contained in:
2026-08-02 18:26:22 +02:00
parent bc2bc7ece5
commit dac19b093c
30 changed files with 2750 additions and 22 deletions
@@ -128,6 +128,7 @@ function createTestDatabase(): DatabaseContext {
name: "Leuchte",
displayName: "Leuchte lokal",
quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
@@ -138,6 +139,7 @@ function createTestDatabase(): DatabaseContext {
name: "Fremdzeile",
displayName: "Fremdzeile",
quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
@@ -175,6 +177,7 @@ describe("circuit device-row project-command repository", () => {
const row = getRow(context);
assert.equal(row.quantity, 2);
assert.equal(row.manualQuantity, 2);
assert.equal(row.roomId, "room-1");
assert.equal(row.remark, null);
assert.equal(row.overriddenFields, "[\"quantity\"]");
@@ -185,6 +188,7 @@ describe("circuit device-row project-command repository", () => {
{ field: "quantity", value: 1 },
{ field: "roomId", value: null },
{ field: "remark", value: null },
{ field: "manualQuantity", value: 1 },
{ field: "overriddenFields", value: null },
],
});
@@ -203,6 +207,7 @@ describe("circuit device-row project-command repository", () => {
{ field: "quantity", value: 2 },
{ field: "roomId", value: "room-1" },
{ field: "remark", value: null },
{ field: "manualQuantity", value: 2 },
{ field: "overriddenFields", value: "[\"quantity\"]" },
],
},
@@ -234,6 +239,7 @@ describe("circuit device-row project-command repository", () => {
const row = getRow(context);
assert.equal(row.quantity, 1);
assert.equal(row.manualQuantity, 1);
assert.equal(row.overriddenFields, null);
assert.equal(undone.revision.revisionNumber, 2);
assert.deepEqual(
@@ -377,4 +383,22 @@ describe("circuit device-row project-command repository", () => {
context.close();
}
});
it("rejects a manual share above the materialized total", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(() => store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
manualQuantity: 2,
}),
}), /manual quantity/);
assert.equal(getRow(context).manualQuantity, 1);
} finally {
context.close();
}
});
});
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import { describe, it } from "node:test";
import Database from "better-sqlite3";
import {
assertCircuitDeviceRowQuantity,
calculateCircuitDeviceRowQuantity,
} from "../src/domain/calculations/circuit-device-row-quantity.js";
describe("circuit device-row quantity", () => {
it("adds the manual share and every indivisible external quantity", () => {
assert.equal(
calculateCircuitDeviceRowQuantity(2, [
{ effectiveQuantity: 2 },
{ effectiveQuantity: 1 },
]),
5
);
assert.doesNotThrow(() => assertCircuitDeviceRowQuantity({
quantity: 5,
manualQuantity: 2,
externalObjects: [{ effectiveQuantity: 2 }, { effectiveQuantity: 1 }],
}));
});
it("rejects invalid shares and a stale materialized total", () => {
assert.throws(
() => calculateCircuitDeviceRowQuantity(-1, []),
/Manual quantity/
);
assert.throws(
() => calculateCircuitDeviceRowQuantity(0, [{ effectiveQuantity: 0 }]),
/External effective quantity/
);
assert.throws(
() => assertCircuitDeviceRowQuantity({
quantity: 2,
manualQuantity: 1,
externalObjects: [{ effectiveQuantity: 2 }],
}),
/does not match/
);
});
it("preserves existing row quantities in migration 0005", () => {
const database = new Database(":memory:");
try {
database.exec("CREATE TABLE circuit_device_rows (id text PRIMARY KEY, quantity integer NOT NULL)");
database.exec("INSERT INTO circuit_device_rows (id, quantity) VALUES ('row-1', 7)");
const migration = fs
.readFileSync("src/db/migrations/0005_stale_gorilla_man.sql", "utf8")
.replaceAll("--> statement-breakpoint", "");
database.exec(migration);
assert.deepEqual(
database.prepare("SELECT quantity, manual_quantity FROM circuit_device_rows").get(),
{ quantity: 7, manual_quantity: 7 }
);
} finally {
database.close();
}
});
});
+2 -1
View File
@@ -21,6 +21,7 @@ import { projectDevices } from "../src/db/schema/project-devices.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import {
@@ -211,7 +212,7 @@ describe("external model snapshot and transfer", () => {
insertLinkedExternalFixture(context.db);
const populated = readProjectStateSnapshot(context.db, "project-1");
assert.ok(populated);
assert.equal(populated.state.schemaVersion, 4);
assert.equal(populated.state.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1");
context.db.delete(externalModelSources).run();
+9 -1
View File
@@ -179,6 +179,7 @@ describe("circuit device-row update project commands", () => {
{ field: "roomId", value: null },
{ field: "quantity", value: 2 },
{ field: "cosPhi", value: 0.9 },
{ field: "manualQuantity", value: 2 },
]);
});
@@ -187,6 +188,13 @@ describe("circuit device-row update project commands", () => {
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {}),
/at least one change/
);
assert.throws(
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 1,
manualQuantity: 2,
}),
/must not exceed/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
@@ -470,7 +478,7 @@ describe("circuit device-row structure project commands", () => {
row.circuitId
);
assert.deepEqual(insert.payload.row, row);
assert.deepEqual(insert.payload.row, { ...row, manualQuantity: 1 });
assert.deepEqual(remove.payload, {
rowId: "row-1",
expectedCircuitId: "circuit-1",
+33
View File
@@ -199,6 +199,39 @@ describe("project state snapshot model", () => {
});
});
it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
const previous = protectedSnapshot();
previous.schemaVersion = 4 as typeof previous.schemaVersion;
previous.circuits[0]!.isReserve = false;
previous.circuits[0]!.deviceRows = [{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 3,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 1,
remark: null,
overriddenFields: null,
}];
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.circuits[0]?.deviceRows[0]?.manualQuantity, 3);
});
it("rejects malformed baseline and unknown snapshot shapes", () => {
const outdated = structuredClone(minimalSnapshot());
delete (outdated.project as Record<string, unknown>).description;