Fix code-review findings across domain, persistence, server and frontend

Full-codebase review turned up five real correctness/security bugs and
a dozen smaller inconsistencies; all are fixed here with matching test
coverage:

- BMK uniqueness silently allowed German-umlaut duplicates ("Ä1" vs
  "ä1") because the DB's normalized index only folds ASCII case. Added
  a shared Unicode-aware pre-check used by every circuit/component
  insert and rename path (one of which had no pre-check at all).
- CircuitDeviceRow.simultaneityFactor had no upper bound at the row
  level (command model and snapshot/restore schema), unlike every
  sibling entity, letting a bad value silently corrupt power totals.
- Grid cell editing silently misread German thousands-separator input
  ("1.500" parsed as 1.5); "." is now rejected outright with a clear
  message instead of guessing.
- The editor's shared command runner (runCommand/applyHistory) had no
  re-entrancy guard, so a double click/drop could fire the same
  command twice and race a BMK collision or revision conflict. Added a
  synchronous ref guard plus isSaving on the buttons that lacked it.
- GET .../next-identifier leaked circuit-numbering state for sections
  in other projects (no ownership check, 400 instead of 404). Moved
  under /projects/:projectId and scoped it.

Also: added the missing circuits.section_id / circuit_device_rows.
circuit_id indexes (migration 0006), gave FormModal a focus trap /
Escape-to-close / focus restore and rebuilt ProjectSettingsModal on
top of it instead of duplicated markup, removed dead code (3 orphaned
domain model files, an unused persistence helper, a wrapper only used
by its own test), pointed the project page at GET /projects/:id
instead of listing+filtering client-side, closed the gap between the
documented 18 MB CSV limit and the ~17.17 MiB actually enforced, added
missing upper bounds on several free-text fields, filled in nine
missing German labels in the revision timeline, replaced a
key-order-fragile JSON.stringify equality check with a real field
comparison, made an implicit sort-order assumption in three
renumbering helpers explicit, cleared the sidebar's target selection
when it no longer resolves after a tree reload, and fixed
updateGlobalDevice to check-then-write instead of write-then-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-08-06 21:31:16 +02:00
parent fa96be2d42
commit b45dc5002d
48 changed files with 3263 additions and 526 deletions

View file

@ -17,13 +17,13 @@ import {
deleteDistributionBoard,
disconnectProjectDeviceRows,
exportProjectTransfer,
getProject,
getProjectDeviceSyncPreview,
listCircuitLists,
listDistributionBoards,
listFloors,
listGlobalDevices,
listProjectDevices,
listProjects,
listRooms,
importProjectTransfer,
synchronizeProjectDeviceRows,
@ -129,7 +129,7 @@ export default function ProjectDetailPage() {
return;
}
Promise.all([
listProjects(),
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
listFloors(projectId),
@ -138,7 +138,7 @@ export default function ProjectDetailPage() {
listGlobalDevices(),
])
.then(([
projects,
currentProject,
distributionBoards,
loadedCircuitLists,
loadedFloors,
@ -146,7 +146,6 @@ export default function ProjectDetailPage() {
loadedProjectDevices,
loadedGlobalDevices,
]) => {
const currentProject = projects.find((item) => item.id === projectId) ?? null;
setProject(currentProject);
setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists);

View file

@ -0,0 +1,2 @@
CREATE INDEX `circuit_device_rows_circuit_id_idx` ON `circuit_device_rows` (`circuit_id`);--> statement-breakpoint
CREATE INDEX `circuits_section_id_idx` ON `circuits` (`section_id`);

File diff suppressed because it is too large Load diff

View file

@ -43,6 +43,13 @@
"when": 1785687503453,
"tag": "0005_stale_gorilla_man",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
}
]
}

View file

@ -43,12 +43,6 @@ export interface CircuitDeviceRowPatchInput {
overriddenFields?: string | null;
}
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder: number;
}
export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) {
return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
@ -105,15 +99,3 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput)
return values;
}
export function toCircuitDeviceRowCreateValues(
id: string,
input: CircuitDeviceRowCreateInput
) {
return {
id,
circuitId: input.circuitId,
sortOrder: input.sortOrder,
...toCircuitDeviceRowUpdateValues(input),
};
}

View file

@ -1,4 +1,4 @@
import { and, eq, ne } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
@ -21,6 +21,7 @@ import {
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
type CircuitRow = typeof circuits.$inferSelect;
@ -166,20 +167,12 @@ export class CircuitProjectCommandRepository
) {
return;
}
const duplicate = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, equipmentIdentifier),
ne(circuits.id, circuit.id)
)
)
.get();
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
assertEquipmentIdentifierAvailable(
database,
circuit.circuitListId,
equipmentIdentifier,
circuit.id
);
}
}

View file

@ -27,6 +27,7 @@ import {
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
@ -103,24 +104,11 @@ export class CircuitStructureProjectCommandRepository
if (existingCircuit) {
throw new Error("Circuit id already exists.");
}
const duplicateIdentifier = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, snapshot.circuitListId),
eq(
circuits.equipmentIdentifier,
assertEquipmentIdentifierAvailable(
database,
snapshot.circuitListId,
snapshot.equipmentIdentifier
)
)
)
.get();
if (duplicateIdentifier) {
throw new Error(
"Duplicate equipmentIdentifier in circuit list."
);
}
if (snapshot.deviceRows.length > 0) {
const rowIds = snapshot.deviceRows.map((row) => row.id);

View file

@ -22,6 +22,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class DistributionBoardComponentStructureProjectCommandRepository
implements DistributionBoardComponentStructureProjectCommandStore
@ -94,6 +95,11 @@ export class DistributionBoardComponentStructureProjectCommandRepository
if (existing) {
throw new Error("Distribution-board component id already exists.");
}
assertEquipmentIdentifierAvailable(
database,
snapshot.component.circuitListId,
snapshot.component.equipmentIdentifier
);
database
.insert(distributionBoardComponents)
.values(snapshot.component)
@ -187,6 +193,17 @@ export class DistributionBoardComponentStructureProjectCommandRepository
"Distribution-board component changed before update."
);
}
if (
target.component.equipmentIdentifier !==
expected.component.equipmentIdentifier
) {
assertEquipmentIdentifierAvailable(
database,
expected.component.circuitListId,
target.component.equipmentIdentifier,
expected.component.id
);
}
database
.update(distributionBoardComponents)
.set(target.component)

View file

@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuitListEquipmentIdentifiers } from "../schema/circuit-list-equipment-identifiers.js";
export function normalizeEquipmentIdentifier(value: string): string {
return value.trim().toLowerCase();
}
/**
* The DB-level normalized unique index uses SQLite's built-in lower(),
* which only folds ASCII a-z and leaves German characters (Ä/Ö/Ü/ß/)
* untouched, so it alone would let e.g. "Ä1" and "ä1" coexist. This check
* normalizes with JS's Unicode-aware toLowerCase() against every
* identifier already registered for the circuit list (circuits and
* distribution-board components share one BMK namespace via
* circuit_list_equipment_identifiers), catching what the DB index cannot.
*/
export function assertEquipmentIdentifierAvailable(
database: AppDatabase,
circuitListId: string,
equipmentIdentifier: string,
excludeOwnerId?: string
): void {
const candidate = normalizeEquipmentIdentifier(equipmentIdentifier);
const existing = database
.select({
ownerId: circuitListEquipmentIdentifiers.ownerId,
equipmentIdentifier: circuitListEquipmentIdentifiers.equipmentIdentifier,
})
.from(circuitListEquipmentIdentifiers)
.where(eq(circuitListEquipmentIdentifiers.circuitListId, circuitListId))
.all();
const duplicate = existing.some(
(row) =>
row.ownerId !== excludeOwnerId &&
normalizeEquipmentIdentifier(row.equipmentIdentifier) === candidate
);
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
}

View file

@ -212,8 +212,10 @@ function replaceExternalState(
if (target.roomMappings.length) {
database.insert(externalRoomMappings).values(target.roomMappings).run();
}
if (target.objects.length) {
database.insert(externalModelObjects).values(target.objects).run();
}
}
function decodeCanonicalBase64(value: string) {
const bytes = Buffer.from(value, "base64");

View file

@ -32,6 +32,7 @@ import {
loadExpectedExternalObjectTransitions,
snapshotsEqual,
} from "./external-object-assignment.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class ExternalObjectNewCircuitProjectCommandRepository
implements ExternalObjectNewCircuitProjectCommandStore
@ -90,12 +91,11 @@ export class ExternalObjectNewCircuitProjectCommandRepository
.where(eq(circuits.id, circuit.id)).get()) {
throw new Error("External circuit id already exists.");
}
if (database.select({ id: circuits.id }).from(circuits).where(and(
eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier)
)).get()) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
assertEquipmentIdentifierAvailable(
database,
circuit.circuitListId,
circuit.equipmentIdentifier
);
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, row.id)).get()) {
throw new Error("External device-row id already exists.");

View file

@ -1,9 +1,11 @@
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { circuits } from "./circuits.js";
import { projectDevices } from "./project-devices.js";
import { rooms } from "./rooms.js";
export const circuitDeviceRows = sqliteTable("circuit_device_rows", {
export const circuitDeviceRows = sqliteTable(
"circuit_device_rows",
{
id: text("id").primaryKey(),
circuitId: text("circuit_id")
.notNull()
@ -31,5 +33,6 @@ export const circuitDeviceRows = sqliteTable("circuit_device_rows", {
cosPhi: real("cos_phi"),
remark: text("remark"),
overriddenFields: text("overridden_fields"),
});
},
(table) => [index("circuit_device_rows_circuit_id_idx").on(table.circuitId)]
);

View file

@ -1,4 +1,4 @@
import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js";
import { circuitSections } from "./circuit-sections.js";
@ -26,6 +26,9 @@ export const circuits = sqliteTable(
isReserve: integer("is_reserve").notNull().default(0),
remark: text("remark"),
},
(table) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)]
(table) => [
unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier),
index("circuits_section_id_idx").on(table.sectionId),
]
);

View file

@ -144,6 +144,9 @@ function assertCircuitDeviceRowUpdateFieldValue(
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number.`);
}
if (field === "simultaneityFactor" && value > 1) {
throw new Error("simultaneityFactor must not exceed 1.");
}
return;
}
if (field === "cosPhi") {

View file

@ -134,6 +134,9 @@ export function assertCircuitDeviceRowInsertProjectCommand(
row.simultaneityFactor,
"row.simultaneityFactor"
);
if (row.simultaneityFactor > 1) {
throw new Error("row.simultaneityFactor must not exceed 1.");
}
if (row.cosPhi !== null) {
assertFiniteNumber(row.cosPhi, "row.cosPhi");
if (row.cosPhi <= 0) {

View file

@ -1,24 +0,0 @@
export interface CircuitDeviceRow {
id: string;
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder: number;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
manualQuantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}

View file

@ -71,15 +71,33 @@ export function assertCircuitProtectionUpdateProjectCommand(
if (target !== null) {
assertCircuitProtectionSnapshot(target, circuitId);
}
if (JSON.stringify(expected) === JSON.stringify(target)) {
if (circuitProtectionSnapshotsEqual(expected, target)) {
throw new Error("Circuit protection update must change state.");
}
}
function circuitProtectionSnapshotsEqual(
left: CircuitProtectionSnapshot | null,
right: CircuitProtectionSnapshot | null
): boolean {
if (left === null || right === null) {
return left === right;
}
return (
left.circuitId === right.circuitId &&
left.type === right.type &&
left.ratedCurrentA === right.ratedCurrentA &&
left.fuseUtilizationCategory === right.fuseUtilizationCategory &&
left.tripCharacteristic === right.tripCharacteristic &&
left.rcdType === right.rcdType &&
left.ratedResidualCurrentMa === right.ratedResidualCurrentMa
);
}
export function assertCircuitProtectionSnapshot(
value: unknown,
circuitId: string
) {
): asserts value is CircuitProtectionSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 7 ||

View file

@ -1,9 +0,0 @@
export interface CircuitSection {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
}

View file

@ -1,19 +0,0 @@
export interface Circuit {
id: string;
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
}

View file

@ -130,7 +130,7 @@ const circuitDeviceRowSchema = z.preprocess(
quantity: finiteNumberSchema.nonnegative(),
manualQuantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema,
overriddenFields: nullableStringSchema,

View file

@ -268,6 +268,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Synchronous re-entry guard: isSaving is React state and only reflects
// reality after the next render, so a second click/drop fired within the
// same tick could otherwise race past it and double-submit a command.
const commandInFlightRef = useRef(false);
const [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
@ -723,6 +727,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
);
}, [data]);
// Clears the sidebar's target selection once it no longer resolves to a
// real section/circuit (e.g. deleted, moved or renumbered elsewhere)
// instead of silently holding a stale id after a tree reload.
useEffect(() => {
if (!data) {
return;
}
if (
targetSectionId &&
!data.sections.some((section) => section.id === targetSectionId)
) {
setTargetSectionId(null);
}
if (
targetCircuitId &&
!circuitOptions.some((option) => option.id === targetCircuitId)
) {
setTargetCircuitId(null);
}
}, [data, circuitOptions, targetSectionId, targetCircuitId]);
const allCircuits = useMemo(
() => data?.sections.flatMap((section) => section.circuits) ?? [],
[data]
@ -1046,6 +1071,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Runs a normal command. The server records it in project-wide history.
async function runCommand(command: HistoryCommand) {
if (commandInFlightRef.current) {
return;
}
commandInFlightRef.current = true;
try {
setError(null);
setIsSaving(true);
@ -1056,6 +1085,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await loadTree({ showLoading: false });
setError(message);
} finally {
commandInFlightRef.current = false;
setIsSaving(false);
}
}
@ -1063,6 +1093,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Applies the next eligible project-wide history operation. Selection is only
// a best-effort local hint; command eligibility and data changes stay server-owned.
async function applyHistory(mode: "undo" | "redo") {
if (commandInFlightRef.current) {
return;
}
commandInFlightRef.current = true;
try {
setError(null);
setHistoryBusy(true);
@ -1088,6 +1122,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await loadTree({ showLoading: false });
setError(message);
} finally {
commandInFlightRef.current = false;
setIsSaving(false);
setHistoryBusy(false);
}
@ -1744,7 +1779,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) {
throw new Error("Bereich wurde nicht gefunden.");
}
const next = await getNextCircuitIdentifier(sectionId);
const next = await getNextCircuitIdentifier(projectId, sectionId);
const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const isDeviceField = deviceFieldKeys.has(key);
@ -1933,7 +1968,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await runCommand({
label: "Stromkreis hinzufügen",
redo: async () => {
const next = await getNextCircuitIdentifier(sectionId);
const next = await getNextCircuitIdentifier(projectId, sectionId);
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
const circuit = createCircuitSnapshot({
sectionId,
@ -2127,7 +2162,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) {
throw new Error("Der Zielbereich ist ungültig.");
}
const next = await getNextCircuitIdentifier(sectionId);
const next = await getNextCircuitIdentifier(projectId, sectionId);
const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const circuit = createCircuitSnapshot(
@ -2538,7 +2573,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await runCommand({
label: newCircuitLabel,
redo: async () => {
const next = await getNextCircuitIdentifier(intent.sectionId);
const next = await getNextCircuitIdentifier(projectId, intent.sectionId);
const sortOrder =
intent.targetCircuitId && intent.placement
? getAdjacentInsertionSortOrder(
@ -3699,6 +3734,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
tabIndex={-1}
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
@ -3716,6 +3752,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
tabIndex={-1}
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
@ -3733,6 +3770,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
tabIndex={-1}
disabled={
isSaving ||
hasActiveSortOrFilter ||
!section.category ||
!canRenumberCircuitGroups(
@ -3758,6 +3796,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button
type="button"
tabIndex={-1}
disabled={isSaving}
title={
canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen"
@ -3815,13 +3854,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
Gruppen-FI hinzufügen
</button>
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
<button
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddReserveCircuit(section.id)}
>
Stromkreis hinzufügen
</button>
<button
type="button"
tabIndex={-1}
disabled={hasActiveSortOrFilter}
disabled={hasActiveSortOrFilter || isSaving}
onClick={() => void handleRenumberSection(section.id)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
>
@ -4426,6 +4470,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
>
Manuelles Gerät hinzufügen
@ -4433,6 +4478,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
>
Stromkreis löschen
@ -4440,7 +4486,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</>
) : null}
{row.device ? (
<button type="button" tabIndex={-1} onClick={() => void handleDeleteDevice(row.device!.id)}>
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
Gerät löschen
</button>
) : null}

View file

@ -1,6 +1,6 @@
"use client";
import React, { type FormEvent, type ReactNode } from "react";
import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react";
interface FormModalProps {
children: ReactNode;
@ -14,6 +14,9 @@ interface FormModalProps {
title: string;
}
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function FormModal({
children,
description,
@ -25,11 +28,56 @@ export function FormModal({
submitLabel,
title,
}: FormModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// Focuses the dialog on open and returns focus to the element that
// triggered it on close, so keyboard users never lose their place in the
// grid behind the backdrop.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const firstFocusable =
dialogRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
firstFocusable?.focus();
return () => {
previouslyFocused?.focus();
};
}, []);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
if (!isSaving) {
event.stopPropagation();
onClose();
}
return;
}
if (event.key !== "Tab" || !dialogRef.current) {
return;
}
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
);
if (focusable.length === 0) {
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
return (
<>
<div
aria-modal="true"
className="modal fade show d-block"
onKeyDown={handleKeyDown}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>

View file

@ -7,6 +7,7 @@ import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board";
import { FormModal } from "./form-modal";
export interface ProjectSettingsInput {
name: string;
@ -131,34 +132,15 @@ export function ProjectSettingsModal({
}
return (
<>
<div
aria-labelledby="project-settings-title"
aria-modal="true"
className="modal fade show d-block"
role="dialog"
tabIndex={-1}
<FormModal
description="Stammdaten und elektrische Standardwerte des Projekts"
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!isValid}
submitLabel="Einstellungen speichern"
title="Projekteinstellungen"
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<form className="modal-content" onSubmit={handleSubmit}>
<div className="modal-header">
<div>
<h2 className="modal-title fs-5" id="project-settings-title">
Projekteinstellungen
</h2>
<p className="text-secondary small mb-0">
Stammdaten und elektrische Standardwerte des Projekts
</p>
</div>
<button
aria-label="Schließen"
className="btn-close"
disabled={isSaving}
onClick={onClose}
type="button"
/>
</div>
<div className="modal-body">
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="project-name">
@ -421,29 +403,7 @@ export function ProjectSettingsModal({
</button>
</div>
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-outline-secondary"
disabled={isSaving}
onClick={onClose}
type="button"
>
Abbrechen
</button>
<button
className="btn btn-primary"
disabled={isSaving || !isValid}
type="submit"
>
{isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
</button>
</div>
</form>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
</FormModal>
);
}

View file

@ -688,9 +688,9 @@ export function deleteCircuitCommand(
);
}
export function getNextCircuitIdentifier(sectionId: string) {
export function getNextCircuitIdentifier(projectId: string, sectionId: string) {
return request<{ sectionId: string; nextIdentifier: string }>(
`/api/circuit-sections/${sectionId}/next-identifier`
`/api/projects/${projectId}/circuit-sections/${sectionId}/next-identifier`
);
}

View file

@ -396,6 +396,16 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
if (trimmed === "") {
return undefined;
}
// A "." is ambiguous in German number entry: it could be a decimal point
// (English convention) or a thousands separator (e.g. "1.500" meaning
// one thousand five hundred). Silently guessing either way risks a wrong
// value entering the power balance without any visible error, so "."
// is rejected outright and "," is the only accepted decimal separator.
if (trimmed.includes(".")) {
throw new Error(
`Ungültiger Zahlenwert in ${cellKey}: Dezimalstellen mit Komma eingeben.`
);
}
const parsed = Number(trimmed.replace(",", "."));
if (Number.isNaN(parsed)) {
throw new Error(`Ungültiger Zahlenwert in ${cellKey}`);

View file

@ -169,13 +169,6 @@ function makeVisibleGridRow(
return { rowKey, rowType, sectionId, circuit, device, cells };
}
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
return buildVisibleGridRowsWithStructure(sections, {
headerComponents: [],
footerComponents: [],
});
}
export function buildVisibleGridRowsWithStructure(
sections: readonly CircuitTreeSectionDto[],
structure: {

View file

@ -259,7 +259,13 @@ export function buildCircuitGroupRenumberPlan(
targetGroupNumber,
expectedPrefix,
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
circuits: group.circuits.map((circuit) => {
circuits: [...group.circuits]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map((circuit) => {
const circuitNumber = parseCircuitNumber(
circuit.equipmentIdentifier,
category,

View file

@ -23,9 +23,13 @@ export function buildCircuitSectionRenumberAssignments(
)
)
);
const orderedCircuits = [...targetSection.circuits].sort(
(left, right) =>
left.sortOrder - right.sortOrder || left.id.localeCompare(right.id)
);
const assignments: CircuitSectionRenumberAssignment[] = [];
let suffix = 1;
for (const circuit of targetSection.circuits) {
for (const circuit of orderedCircuits) {
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
while (
identifiersOutsideSection.has(targetEquipmentIdentifier)

View file

@ -64,7 +64,7 @@ export function buildCircuitStructureProjection(
component,
});
}
for (const circuit of section.circuits) {
for (const circuit of ordered(section.circuits)) {
rows.push({
rowKey: `circuit-block:${circuit.id}`,
rowType: "circuitBlock",

View file

@ -47,10 +47,24 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.update": "Geschoss bearbeitet",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
"project-room.update": "Raum bearbeitet",
"project-room.delete": "Raum entfernt",
"project.restore-state": "Projektstand wiederhergestellt",
"circuit-protection.update": "Stromkreisschutz bearbeitet",
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen",
"external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen",
"external-object.unassign-and-delete-created-circuit":
"Externes Objekt aus erzeugtem Stromkreis gelöst",
"external-object.assign-to-new-row":
"Externes Objekt in neue Gerätezeile übernommen",
"external-object.unassign-and-delete-created-row":
"Externes Objekt aus erzeugter Gerätezeile gelöst",
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
};
export function getProjectRevisionSourceLabel(

View file

@ -1,10 +1,21 @@
import type { Request, Response } from "express";
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
import {
circuitListRepository,
circuitSectionRepository,
} from "../composition/application-repositories.js";
export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
const { projectId, sectionId } = req.params;
if (typeof projectId !== "string" || typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const section = await circuitSectionRepository.findById(sectionId);
const list = section
? await circuitListRepository.findById(projectId, section.circuitListId)
: null;
if (!section || !list) {
return res.status(404).json({ error: "Section not found" });
}
try {
const nextIdentifier =

View file

@ -34,11 +34,12 @@ export async function updateGlobalDevice(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() });
}
await globalDeviceRepository.update(globalDeviceId, parsed.data);
const row = await globalDeviceRepository.findById(globalDeviceId);
if (!row) {
const existing = await globalDeviceRepository.findById(globalDeviceId);
if (!existing) {
return res.status(404).json({ error: "Global device not found" });
}
await globalDeviceRepository.update(globalDeviceId, parsed.data);
const row = await globalDeviceRepository.findById(globalDeviceId);
return res.json(row);
}

View file

@ -1,5 +1,4 @@
import express from "express";
import { circuitRouter } from "./routes/circuit.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js";
@ -48,7 +47,6 @@ app.get("/health", (_req, res) => {
});
app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);

View file

@ -1,9 +0,0 @@
import { Router } from "express";
import {
getNextCircuitIdentifier,
} from "../controllers/circuit.controller.js";
export const circuitRouter = Router();
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);

View file

@ -16,6 +16,7 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
import {
getProjectHistory,
listProjectRevisions,
@ -95,6 +96,10 @@ projectRouter.delete(
);
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
projectRouter.get(
"/:projectId/circuit-sections/:sectionId/next-identifier",
getNextCircuitIdentifier
);
projectRouter.get("/:projectId/floors", listFloorsByProject);
projectRouter.post("/:projectId/floors", createFloor);
projectRouter.put("/:projectId/floors/:floorId", updateFloor);

View file

@ -1,5 +1,10 @@
import { z } from "zod";
// Base64 length of the 18 MiB transport limit documented for CSV uploads
// (18 * 1024 * 1024 bytes, evenly divisible by 3, so ceil(N/3)*4 with no
// padding). Keep in sync with the controller's raw byte-length check.
export const MAX_CSV_CONTENT_BASE64_LENGTH = 25_165_824;
export const updateExternalCsvConfigurationSchema = z
.object({
expectedRevision: z.number().int().nonnegative(),
@ -10,7 +15,7 @@ export const updateExternalCsvConfigurationSchema = z
export const previewExternalCsvSchema = z
.object({
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(24_000_000),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
})
.strict();
@ -21,7 +26,7 @@ export const applyExternalInitialImportSchema = z.object({
expectedConfigurationVersion: z.number().int().positive(),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(24_000_000),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
sourceName: z.string().trim().min(1).max(200),
roomDecisions: z.array(z.object({
sourceRoomKey: z.string().trim().min(1),

View file

@ -1,15 +1,15 @@
import { z } from "zod";
export const createGlobalDeviceSchema = z.object({
name: z.string().min(1),
displayName: z.string().min(1),
category: z.string().optional(),
name: z.string().min(1).max(200),
displayName: z.string().min(1).max(200),
category: z.string().max(100).optional(),
quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1),
phaseCount: z.union([z.literal(1), z.literal(3)]),
powerFactor: z.number().min(0).max(1).optional(),
note: z.string().optional(),
note: z.string().max(2000).optional(),
}).strict();
export const updateGlobalDeviceSchema = createGlobalDeviceSchema;

View file

@ -4,16 +4,16 @@ import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
import { circuitGroupCategories } from "../constants/circuit-group.js";
export const createProjectDeviceSchema = z.object({
name: z.string().min(1),
displayName: z.string().min(1),
connectionKind: z.string().optional(),
costGroup: z.string().optional(),
name: z.string().min(1).max(200),
displayName: z.string().min(1).max(200),
connectionKind: z.string().max(100).optional(),
costGroup: z.string().max(100).optional(),
category: z.enum(circuitGroupCategories),
quantity: z.number().min(0),
powerPerUnit: z.number().min(0),
simultaneityFactor: z.number().min(0).max(1),
cosPhi: z.number().min(0).max(1).optional(),
remark: z.string().optional(),
remark: z.string().max(2000).optional(),
}).strict();
export const updateProjectDeviceSchema = createProjectDeviceSchema;

View file

@ -36,7 +36,7 @@ export const updateProjectSettingsSchema = z
export const createDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
name: z.string().trim().min(1).max(200),
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
})
@ -67,7 +67,7 @@ export const deleteDistributionBoardSchema = z
export const createFloorSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
name: z.string().trim().min(1).max(200),
})
.strict();
@ -83,8 +83,8 @@ export const createRoomSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).optional(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
roomNumber: z.string().trim().min(1).max(50),
roomName: z.string().trim().min(1).max(200),
})
.strict();
@ -92,8 +92,8 @@ export const updateRoomSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
roomNumber: z.string().trim().min(1).max(50),
roomName: z.string().trim().min(1).max(200),
})
.strict();

View file

@ -187,12 +187,25 @@ describe("circuit grid model", () => {
});
it("parses numeric drafts and rejects invalid values", () => {
assert.equal(parseNumeric("quantity", " 2.5 "), 2.5);
assert.equal(parseNumeric("quantity", " 1500 "), 1500);
assert.equal(parseNumeric("powerPerUnit", " 1,25 "), 1.25);
assert.equal(parseNumeric("quantity", ""), undefined);
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
});
it("rejects a dot instead of silently misreading it as a thousands separator", () => {
// "1.500" typed with German thousands-separator intent (meaning 1500)
// must never silently become 1.5 — it must fail loudly instead.
assert.throws(
() => parseNumeric("cableLength", "1.500"),
/Dezimalstellen mit Komma eingeben/
);
assert.throws(
() => parseNumeric("powerPerUnit", "2.5"),
/Dezimalstellen mit Komma eingeben/
);
});
it("builds nullable circuit command patches from grid drafts", () => {
assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
voltage: null,

View file

@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildVisibleGridRows,
buildVisibleGridRowsWithStructure,
filterAndSortCircuitSections,
getDistinctFilterValues,
@ -101,7 +100,7 @@ describe("circuit grid projection", () => {
];
const projectedSections = filterAndSortCircuitSections(emptySections, {}, null);
const rows = buildVisibleGridRows(projectedSections);
const rows = buildVisibleGridRowsWithStructure(projectedSections, { headerComponents: [], footerComponents: [] });
assert.equal(projectedSections.length, 2);
assert.deepEqual(
@ -150,7 +149,7 @@ describe("circuit grid projection", () => {
});
it("builds the compact, grouped, reserve and placeholder row shapes", () => {
const rows = buildVisibleGridRows(sections);
const rows = buildVisibleGridRowsWithStructure(sections, { headerComponents: [], footerComponents: [] });
assert.deepEqual(rows.map((row) => row.rowType), [
"section",
@ -191,7 +190,7 @@ describe("circuit grid projection", () => {
},
})),
}));
const rows = buildVisibleGridRows(protectedSections);
const rows = buildVisibleGridRowsWithStructure(protectedSections, { headerComponents: [], footerComponents: [] });
const protectionValue = (rowType: string) =>
rows
.find((row) => row.rowType === rowType)

View file

@ -580,4 +580,43 @@ describe("circuit structure project-command repository", () => {
fixture.context.close();
}
});
it("rejects a BMK that differs from an existing one only by German umlaut casing", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "umlaut-original",
equipmentIdentifier: "-1F9Ä",
deviceRows: [],
})
),
});
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "umlaut-duplicate",
equipmentIdentifier: "-1f9ä",
deviceRows: [],
})
),
}),
/Duplicate equipmentIdentifier in circuit list\./
);
} finally {
fixture.context.close();
}
});
});

View file

@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getNextCircuitIdentifier } from "../src/server/controllers/circuit.controller.js";
import {
circuitListRepository,
circuitSectionRepository,
} from "../src/server/composition/application-repositories.js";
import { circuitNumberingService } from "../src/server/composition/circuit-numbering-service.js";
function createMockResponse() {
let statusCode = 200;
let body: unknown;
return {
response: {
status(code: number) {
statusCode = code;
return this;
},
json(value: unknown) {
body = value;
return this;
},
},
getStatusCode: () => statusCode,
getBody: () => body,
};
}
describe("circuit controller", () => {
it("returns the next identifier when the section belongs to the project", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
findList: circuitListRepository.findById,
getNextIdentifier: circuitNumberingService.getNextIdentifier,
};
circuitSectionRepository.findById = async () =>
({ id: "section-1", circuitListId: "list-1", prefix: "-1F" }) as never;
circuitListRepository.findById = async (projectId: string, circuitListId: string) =>
projectId === "project-1" && circuitListId === "list-1"
? ({ id: "list-1", projectId: "project-1" } as never)
: null;
circuitNumberingService.getNextIdentifier = async () => "-1F3";
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "project-1", sectionId: "section-1" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
circuitListRepository.findById = originals.findList;
circuitNumberingService.getNextIdentifier = originals.getNextIdentifier;
}
assert.deepEqual(mock.getBody(), {
sectionId: "section-1",
nextIdentifier: "-1F3",
});
});
it("returns 404 instead of leaking numbering state for a section from another project", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
findList: circuitListRepository.findById,
};
circuitSectionRepository.findById = async () =>
({ id: "section-1", circuitListId: "list-1", prefix: "-1F" }) as never;
// The section exists, but its circuit list does not belong to the requesting project.
circuitListRepository.findById = async () => null;
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "foreign-project", sectionId: "section-1" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
circuitListRepository.findById = originals.findList;
}
assert.equal(mock.getStatusCode(), 404);
assert.deepEqual(mock.getBody(), { error: "Section not found" });
});
it("returns 404 for a section that does not exist", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
};
circuitSectionRepository.findById = async () => null;
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "project-1", sectionId: "missing" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
}
assert.equal(mock.getStatusCode(), 404);
assert.deepEqual(mock.getBody(), { error: "Section not found" });
});
});

View file

@ -580,7 +580,7 @@ describe("distribution-board component structure project command", () => {
snapshot
),
}),
/UNIQUE constraint failed/
/Duplicate equipmentIdentifier in circuit list\./
);
assert.equal(
context.db.select().from(projectRevisions).all().length,

View file

@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
applyExternalInitialImportSchema,
MAX_CSV_CONTENT_BASE64_LENGTH,
planExternalInitialImportSchema,
previewExternalCsvSchema,
updateExternalCsvConfigurationSchema,
@ -74,7 +75,7 @@ describe("external CSV API contracts", () => {
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "a".repeat(24_000_001),
contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1),
}).success,
false
);

View file

@ -195,6 +195,30 @@ describe("external initial import project command", () => {
}
});
it("rejects a target state with zero classified objects before writing anything", () => {
// assertPopulatedInitialState requires at least one object, so a CSV
// that classified none must be rejected at command construction, not
// reach the persistence layer's bulk insert.
const { context, configuration } = createTestContext();
try {
const target: ExternalModelStateSnapshot = {
...initialState(configuration),
roomMappings: [],
objects: [],
};
assert.throws(
() =>
createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
/at least one object/
);
} finally {
context.close();
}
});
it("rejects changed state, checksum drift and row assignment", () => {
const { context, configuration } = createTestContext();
try {

View file

@ -209,6 +209,13 @@ describe("circuit device-row update project commands", () => {
}),
/non-negative/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
simultaneityFactor: 1.5,
}),
/must not exceed 1/
);
});
});
@ -508,6 +515,14 @@ describe("circuit device-row structure project commands", () => {
}),
/must not be negative/
);
assert.throws(
() =>
createCircuitDeviceRowInsertProjectCommand({
...row,
simultaneityFactor: 1.5,
}),
/must not exceed 1/
);
assert.throws(
() => createCircuitDeviceRowDeleteProjectCommand("", "circuit-1"),
/rowId/

View file

@ -191,6 +191,32 @@ describe("project version history presentation", () => {
),
"Stromkreisgruppe vollständig wiederhergestellt"
);
assert.equal(
getProjectRevisionDescription(
revision(16, { commandType: "circuit-protection.update" })
),
"Stromkreisschutz bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(17, { commandType: "project-floor.update" })
),
"Geschoss bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(18, { commandType: "project-room.update" })
),
"Raum bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(19, {
commandType: "external-object.update-row-assignment",
})
),
"Externe Objektzuordnung geändert"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal(
getProjectSnapshotKindLabel("automatic"),