Add explicit "no protection device" state for circuits

Circuits can now record that they deliberately have no protection device
(type NONE, shown as "–"), separate from circuits whose protection is
still open. Migration 0017 makes rated_current_a nullable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 13:11:28 +01:00
parent 1f3b903809
commit 38019fe087
27 changed files with 3673 additions and 132 deletions

View file

@ -70,6 +70,9 @@ See `docs/current-architecture.md` for the complete module and request flow.
- Equipment identifiers belong to circuits, not to every device row. - Equipment identifiers belong to circuits, not to every device row.
- Device rows inside a circuit do not have their own equipment identifiers. - Device rows inside a circuit do not have their own equipment identifiers.
- Protection and cable data belong to the circuit, not to individual devices. - Protection and cable data belong to the circuit, not to individual devices.
- A circuit protection record of type `NONE` means "deliberately no protection
device" (shown as "–"); a circuit without any protection record is still
open. `NONE` is circuit-only, never a component protection.
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category. - Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
- Circuit total power is the sum of all device rows in that circuit plus the - Circuit total power is the sum of all device rows in that circuit plus the
read-only loads of logically linked downstream boards on that feeder circuit. read-only loads of logically linked downstream boards on that feeder circuit.

View file

@ -460,6 +460,13 @@ Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang
atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen; atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen;
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
wiederherstellen. wiederherstellen.
Der Typ `NONE` („Kein Schutzgerät“) hält die bewusste Entscheidung fest, dass
ein Stromkreis kein eigenes Schutzgerät erhält; Bemessungsstrom und alle
abhängigen Felder sind dann `null` (Migration `0017` macht
`rated_current_a` nullable). Editor und Seitenansicht zeigen „–“. Ein
Stromkreis ganz ohne Datensatz gilt dagegen weiter als offen (Seitenansicht
„offen“). `NONE` gibt es nur für Stromkreise, nicht für
Verteilerkomponenten; die Leitungsberechnung übernimmt dafür kein Schutzgerät.
Der vollständige `CircuitSnapshot` enthält den zugehörigen Der vollständige `CircuitSnapshot` enthält den zugehörigen
`protectionDevice`-Datensatz, sofern einer vorhanden ist. Neue Stromkreise und `protectionDevice`-Datensatz, sofern einer vorhanden ist. Neue Stromkreise und
durch Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten durch Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten

View file

@ -0,0 +1,16 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_circuit_protection_devices` (
`circuit_id` text PRIMARY KEY NOT NULL,
`type` text NOT NULL,
`rated_current_a` real,
`fuse_utilization_category` text,
`trip_characteristic` text,
`rcd_type` text,
`rated_residual_current_ma` real,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_circuit_protection_devices`("circuit_id", "type", "rated_current_a", "fuse_utilization_category", "trip_characteristic", "rcd_type", "rated_residual_current_ma") SELECT "circuit_id", "type", "rated_current_a", "fuse_utilization_category", "trip_characteristic", "rcd_type", "rated_residual_current_ma" FROM `circuit_protection_devices`;--> statement-breakpoint
DROP TABLE `circuit_protection_devices`;--> statement-breakpoint
ALTER TABLE `__new_circuit_protection_devices` RENAME TO `circuit_protection_devices`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load diff

View file

@ -120,6 +120,13 @@
"when": 1793277078601, "when": 1793277078601,
"tag": "0016_single_device_circuit_names", "tag": "0016_single_device_circuit_names",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1793359753805,
"tag": "0017_circuit_protection_none",
"breakpoints": true
} }
] ]
} }

View file

@ -1,8 +1,8 @@
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type { import type {
BreakerTripCharacteristic, BreakerTripCharacteristic,
CircuitProtectionDeviceType,
FuseUtilizationCategory, FuseUtilizationCategory,
ProtectionDeviceType,
RcdType, RcdType,
} from "../../shared/constants/protection-device.js"; } from "../../shared/constants/protection-device.js";
import { circuits } from "./circuits.js"; import { circuits } from "./circuits.js";
@ -13,8 +13,9 @@ export const circuitProtectionDevices = sqliteTable(
circuitId: text("circuit_id") circuitId: text("circuit_id")
.primaryKey() .primaryKey()
.references(() => circuits.id, { onDelete: "cascade" }), .references(() => circuits.id, { onDelete: "cascade" }),
type: text("type").$type<ProtectionDeviceType>().notNull(), // "NONE" stores an explicit "no protection device" with null values.
ratedCurrentA: real("rated_current_a").notNull(), type: text("type").$type<CircuitProtectionDeviceType>().notNull(),
ratedCurrentA: real("rated_current_a"),
fuseUtilizationCategory: text( fuseUtilizationCategory: text(
"fuse_utilization_category" "fuse_utilization_category"
).$type<FuseUtilizationCategory>(), ).$type<FuseUtilizationCategory>(),

View file

@ -1,10 +1,10 @@
import type { import type {
BreakerTripCharacteristic, BreakerTripCharacteristic,
CircuitProtectionDeviceType,
FuseUtilizationCategory, FuseUtilizationCategory,
ProtectionDeviceType,
RcdType, RcdType,
} from "../../shared/constants/protection-device.js"; } from "../../shared/constants/protection-device.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js"; import { isValidCircuitProtectionRecord } from "../../shared/validation/protection-device.schemas.js";
import { import {
assertCircuitGroupSnapshot, assertCircuitGroupSnapshot,
type CircuitGroupSnapshot, type CircuitGroupSnapshot,
@ -22,8 +22,8 @@ import {
export interface CircuitProtectionDeviceSnapshot { export interface CircuitProtectionDeviceSnapshot {
circuitId: string; circuitId: string;
type: ProtectionDeviceType; type: CircuitProtectionDeviceType;
ratedCurrentA: number; ratedCurrentA: number | null;
fuseUtilizationCategory: FuseUtilizationCategory | null; fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null; tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null; rcdType: RcdType | null;
@ -143,21 +143,7 @@ function assertCircuitProtectionDeviceSnapshot(
) { ) {
throw new Error("Circuit protection-device snapshot is invalid."); throw new Error("Circuit protection-device snapshot is invalid.");
} }
const result = protectionDeviceConfigurationSchema.safeParse({ if (!isValidCircuitProtectionRecord(value)) {
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
throw new Error( throw new Error(
"Circuit protection-device configuration is invalid." "Circuit protection-device configuration is invalid."
); );

View file

@ -1,8 +1,8 @@
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js"; import { isValidCircuitProtectionRecord } from "../../shared/validation/protection-device.schemas.js";
import type { import type {
BreakerTripCharacteristic, BreakerTripCharacteristic,
CircuitProtectionDeviceType,
FuseUtilizationCategory, FuseUtilizationCategory,
ProtectionDeviceType,
RcdType, RcdType,
} from "../../shared/constants/protection-device.js"; } from "../../shared/constants/protection-device.js";
import type { SerializedProjectCommand } from "./project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js";
@ -11,10 +11,12 @@ export const circuitProtectionUpdateCommandType =
"circuit-protection.update" as const; "circuit-protection.update" as const;
export const circuitProtectionUpdateCommandSchemaVersion = 1 as const; export const circuitProtectionUpdateCommandSchemaVersion = 1 as const;
// type "NONE" records an explicit "no protection device" decision; all
// values are then null. A missing snapshot (null) means "not yet planned".
export interface CircuitProtectionSnapshot { export interface CircuitProtectionSnapshot {
circuitId: string; circuitId: string;
type: ProtectionDeviceType; type: CircuitProtectionDeviceType;
ratedCurrentA: number; ratedCurrentA: number | null;
fuseUtilizationCategory: FuseUtilizationCategory | null; fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null; tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null; rcdType: RcdType | null;
@ -105,21 +107,7 @@ export function assertCircuitProtectionSnapshot(
) { ) {
throw new Error("Circuit-protection snapshot is invalid."); throw new Error("Circuit-protection snapshot is invalid.");
} }
const result = protectionDeviceConfigurationSchema.safeParse({ if (!isValidCircuitProtectionRecord(value)) {
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
throw new Error("Circuit-protection configuration is invalid."); throw new Error("Circuit-protection configuration is invalid.");
} }
} }

View file

@ -45,7 +45,7 @@ export interface CircuitTreeCircuit {
circuitCosPhi: number; circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRow[]; deviceRows: CircuitTreeDeviceRow[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoad[]; downstreamBoardLoads: CircuitTreeDownstreamBoardLoad[];
protectionDevice?: CircuitTreeProtectionDevice; protectionDevice?: CircuitTreeCircuitProtectionDevice;
} }
export interface CircuitTreeSectionBlock { export interface CircuitTreeSectionBlock {
@ -116,6 +116,7 @@ import type {
import type { import type {
BreakerTripCharacteristic, BreakerTripCharacteristic,
FuseUtilizationCategory, FuseUtilizationCategory,
NoCircuitProtectionDeviceType,
ProtectionDeviceType, ProtectionDeviceType,
RcdType, RcdType,
} from "../../shared/constants/protection-device.js"; } from "../../shared/constants/protection-device.js";
@ -129,6 +130,15 @@ export interface CircuitTreeProtectionDevice {
ratedResidualCurrentMa?: number; ratedResidualCurrentMa?: number;
} }
// Explicit "no protection device" on a circuit; shown as "–".
export interface CircuitTreeNoProtectionDevice {
type: NoCircuitProtectionDeviceType;
}
export type CircuitTreeCircuitProtectionDevice =
| CircuitTreeProtectionDevice
| CircuitTreeNoProtectionDevice;
export interface CircuitTreeComponent { export interface CircuitTreeComponent {
id: string; id: string;
circuitListId: string; circuitListId: string;

View file

@ -15,9 +15,13 @@ import {
distributionBoardComponentPlacements, distributionBoardComponentPlacements,
distributionBoardComponentRoles, distributionBoardComponentRoles,
} from "../../shared/constants/distribution-board-component.js"; } from "../../shared/constants/distribution-board-component.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js"; import {
isValidCircuitProtectionRecord,
isValidProtectionRecord,
} from "../../shared/validation/protection-device.schemas.js";
import { import {
breakerTripCharacteristics, breakerTripCharacteristics,
circuitProtectionDeviceTypes,
fuseUtilizationCategories, fuseUtilizationCategories,
protectionDeviceTypes, protectionDeviceTypes,
rcdTypes, rcdTypes,
@ -311,45 +315,15 @@ const distributionBoardComponentSchema = z
}) })
.strict(); .strict();
const persistedProtectionDeviceFields = { const persistedProtectionDeviceOptionFields = {
type: z.enum(protectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive(),
fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(), fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(),
tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(), tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(),
rcdType: z.enum(rcdTypes).nullable(), rcdType: z.enum(rcdTypes).nullable(),
ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(), ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(),
}; };
function validatePersistedProtectionDevice( function addProtectionIssue(valid: boolean, context: z.RefinementCtx) {
value: { if (!valid) {
type: (typeof protectionDeviceTypes)[number];
ratedCurrentA: number;
fuseUtilizationCategory:
| (typeof fuseUtilizationCategories)[number]
| null;
tripCharacteristic:
| (typeof breakerTripCharacteristics)[number]
| null;
rcdType: (typeof rcdTypes)[number] | null;
ratedResidualCurrentMa: number | null;
},
context: z.RefinementCtx
) {
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
context.addIssue({ context.addIssue({
code: "custom", code: "custom",
message: "Snapshot protection-device configuration is invalid.", message: "Snapshot protection-device configuration is invalid.",
@ -357,21 +331,30 @@ function validatePersistedProtectionDevice(
} }
} }
// Circuits may additionally store the explicit "no protection device" state.
const circuitProtectionDeviceSchema = z const circuitProtectionDeviceSchema = z
.object({ .object({
circuitId: idSchema, circuitId: idSchema,
...persistedProtectionDeviceFields, type: z.enum(circuitProtectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive().nullable(),
...persistedProtectionDeviceOptionFields,
}) })
.strict() .strict()
.superRefine(validatePersistedProtectionDevice); .superRefine((value, context) =>
addProtectionIssue(isValidCircuitProtectionRecord(value), context)
);
const componentProtectionDeviceSchema = z const componentProtectionDeviceSchema = z
.object({ .object({
componentId: idSchema, componentId: idSchema,
...persistedProtectionDeviceFields, type: z.enum(protectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive(),
...persistedProtectionDeviceOptionFields,
}) })
.strict() .strict()
.superRefine(validatePersistedProtectionDevice); .superRefine((value, context) =>
addProtectionIssue(isValidProtectionRecord(value), context)
);
const externalCsvConfigurationSnapshotSchema = z const externalCsvConfigurationSnapshotSchema = z
.object({ .object({

View file

@ -4,7 +4,11 @@ import type {
CircuitCategory, CircuitCategory,
ExistingProtection, ExistingProtection,
} from "../../cable-sizing/domain/cable-sizing-calculation"; } from "../../cable-sizing/domain/cable-sizing-calculation";
import type { CircuitTreeCircuitDto, CircuitTreeProtectionDeviceDto } from "../types"; import { noCircuitProtectionDeviceType } from "../../shared/constants/protection-device";
import type {
CircuitTreeCircuitDto,
CircuitTreeCircuitProtectionDeviceDto,
} from "../types";
/** Three-phase if the circuit group or any device row is three-phase. */ /** Three-phase if the circuit group or any device row is three-phase. */
export function deriveCircuitPhase( export function deriveCircuitPhase(
@ -41,12 +45,17 @@ export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
* Maps the circuit's protective device to the overload-protection kind of the * Maps the circuit's protective device to the overload-protection kind of the
* calculation. Circuit-breakers (LS, FI/LS, AFDD) are "breaker", D0 and NH * calculation. Circuit-breakers (LS, FI/LS, AFDD) are "breaker", D0 and NH
* fuse links "fuse"; a residual-current device without overcurrent release * fuse links "fuse"; a residual-current device without overcurrent release
* (FI) provides no overload protection and is ignored. * (FI) provides no overload protection and is ignored, like an explicit
* "no protection device".
*/ */
export function toExistingProtection( export function toExistingProtection(
protection: CircuitTreeProtectionDeviceDto | undefined protection: CircuitTreeCircuitProtectionDeviceDto | undefined
): ExistingProtection | undefined { ): ExistingProtection | undefined {
if (!protection || protection.type === "FI") { if (
!protection ||
protection.type === "FI" ||
protection.type === noCircuitProtectionDeviceType
) {
return undefined; return undefined;
} }
const kind = const kind =

View file

@ -4,23 +4,24 @@ import { type FormEvent, useState } from "react";
import { import {
allowedRatedCurrentsAByProtectionDeviceType, allowedRatedCurrentsAByProtectionDeviceType,
breakerTripCharacteristics, breakerTripCharacteristics,
circuitProtectionDeviceTypeLabels,
fuseProtectionDeviceTypes, fuseProtectionDeviceTypes,
fuseUtilizationCategories, fuseUtilizationCategories,
protectionDeviceTypeLabels, noCircuitProtectionDeviceType,
protectionDeviceTypes, protectionDeviceTypes,
ratedResidualCurrentsMa, ratedResidualCurrentsMa,
rcdTypes, rcdTypes,
type ProtectionDeviceType, type CircuitProtectionDeviceType,
} from "../../shared/constants/protection-device"; } from "../../shared/constants/protection-device";
import type { CircuitTreeProtectionDeviceDto } from "../types"; import type { CircuitTreeCircuitProtectionDeviceDto } from "../types";
import { FormModal } from "./form-modal"; import { FormModal } from "./form-modal";
interface CircuitProtectionModalProps { interface CircuitProtectionModalProps {
equipmentIdentifier: string; equipmentIdentifier: string;
initialProtection: CircuitTreeProtectionDeviceDto; initialProtection: CircuitTreeCircuitProtectionDeviceDto;
isSaving: boolean; isSaving: boolean;
onClose: () => void; onClose: () => void;
onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise<void>; onSave: (protection: CircuitTreeCircuitProtectionDeviceDto) => Promise<void>;
} }
export function CircuitProtectionModal({ export function CircuitProtectionModal({
@ -30,35 +31,45 @@ export function CircuitProtectionModal({
onClose, onClose,
onSave, onSave,
}: CircuitProtectionModalProps) { }: CircuitProtectionModalProps) {
const [type, setType] = useState(initialProtection.type); const initialDevice =
initialProtection.type === noCircuitProtectionDeviceType
? undefined
: initialProtection;
const [type, setType] = useState<CircuitProtectionDeviceType>(
initialProtection.type
);
const [ratedCurrentA, setRatedCurrentA] = useState( const [ratedCurrentA, setRatedCurrentA] = useState(
initialProtection.ratedCurrentA initialDevice?.ratedCurrentA ?? 0
); );
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState( const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
initialProtection.fuseUtilizationCategory ?? "gG" initialDevice?.fuseUtilizationCategory ?? "gG"
); );
const [tripCharacteristic, setTripCharacteristic] = useState( const [tripCharacteristic, setTripCharacteristic] = useState(
initialProtection.tripCharacteristic ?? "B" initialDevice?.tripCharacteristic ?? "B"
); );
const [rcdType, setRcdType] = useState( const [rcdType, setRcdType] = useState(
initialProtection.rcdType ?? "A" initialDevice?.rcdType ?? "A"
); );
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState( const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
initialProtection.ratedResidualCurrentMa ?? 30 initialDevice?.ratedResidualCurrentMa ?? 30
); );
const hasNoDevice = type === noCircuitProtectionDeviceType;
const usesFuseCategory = ( const usesFuseCategory = (
fuseProtectionDeviceTypes as readonly string[] fuseProtectionDeviceTypes as readonly string[]
).includes(type); ).includes(type);
const usesTripCharacteristic = const usesTripCharacteristic =
type === "LS" || type === "FI_LS" || type === "AFDD"; type === "LS" || type === "FI_LS" || type === "AFDD";
const usesResidualCurrent = type === "FI" || type === "FI_LS"; const usesResidualCurrent = type === "FI" || type === "FI_LS";
const allowedRatedCurrents = const allowedRatedCurrents: readonly number[] = hasNoDevice
allowedRatedCurrentsAByProtectionDeviceType[type]; ? []
: allowedRatedCurrentsAByProtectionDeviceType[type];
function handleTypeChange(nextType: ProtectionDeviceType) { function handleTypeChange(nextType: CircuitProtectionDeviceType) {
setType(nextType); setType(nextType);
setRatedCurrentA( setRatedCurrentA(
allowedRatedCurrentsAByProtectionDeviceType[nextType][0] nextType === noCircuitProtectionDeviceType
? 0
: allowedRatedCurrentsAByProtectionDeviceType[nextType][0]
); );
setFuseUtilizationCategory("gG"); setFuseUtilizationCategory("gG");
setTripCharacteristic("B"); setTripCharacteristic("B");
@ -68,6 +79,10 @@ export function CircuitProtectionModal({
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (type === noCircuitProtectionDeviceType) {
await onSave({ type });
return;
}
await onSave({ await onSave({
type, type,
ratedCurrentA, ratedCurrentA,
@ -86,9 +101,7 @@ export function CircuitProtectionModal({
onClose={onClose} onClose={onClose}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitDisabled={ submitDisabled={
!(allowedRatedCurrents as readonly number[]).includes( !hasNoDevice && !allowedRatedCurrents.includes(ratedCurrentA)
ratedCurrentA
)
} }
submitLabel="Schutzgerät speichern" submitLabel="Schutzgerät speichern"
title={`Schutzgerät bearbeiten – ${equipmentIdentifier}`} title={`Schutzgerät bearbeiten – ${equipmentIdentifier}`}
@ -104,18 +117,29 @@ export function CircuitProtectionModal({
id="circuit-protection-type" id="circuit-protection-type"
onChange={(event) => onChange={(event) =>
handleTypeChange( handleTypeChange(
event.target.value as ProtectionDeviceType event.target.value as CircuitProtectionDeviceType
) )
} }
value={type} value={type}
> >
<option value={noCircuitProtectionDeviceType}>
{circuitProtectionDeviceTypeLabels.NONE} (–)
</option>
{protectionDeviceTypes.map((entry) => ( {protectionDeviceTypes.map((entry) => (
<option key={entry} value={entry}> <option key={entry} value={entry}>
{protectionDeviceTypeLabels[entry]} {circuitProtectionDeviceTypeLabels[entry]}
</option> </option>
))} ))}
</select> </select>
</div> </div>
{hasNoDevice ? (
<div className="col-12">
<p className="text-secondary small mb-0">
Der Stromkreis erhält bewusst kein eigenes Schutzgerät und wird
in Liste und Seitenansicht mit „–“ geführt.
</p>
</div>
) : (
<div className="col-12 col-md-6"> <div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-protection-current"> <label className="form-label" htmlFor="circuit-protection-current">
Bemessungsstrom Bemessungsstrom
@ -135,6 +159,7 @@ export function CircuitProtectionModal({
))} ))}
</select> </select>
</div> </div>
)}
{usesFuseCategory ? ( {usesFuseCategory ? (
<div className="col-12 col-md-6"> <div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-fuse-category"> <label className="form-label" htmlFor="circuit-fuse-category">

View file

@ -170,8 +170,8 @@ import {
} from "../utils/circuit-grid-clipboard"; } from "../utils/circuit-grid-clipboard";
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeCircuitProtectionDeviceDto,
CircuitTreeComponentDto, CircuitTreeComponentDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto, CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto, CreateCircuitInputDto,
@ -1561,7 +1561,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
async function handleSaveCircuitProtection( async function handleSaveCircuitProtection(
protection: CircuitTreeProtectionDeviceDto protection: CircuitTreeCircuitProtectionDeviceDto
) { ) {
const circuit = protectionEditorCircuit; const circuit = protectionEditorCircuit;
if (!circuit) { if (!circuit) {

View file

@ -1,8 +1,12 @@
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device"; import {
noCircuitProtectionDeviceText,
noCircuitProtectionDeviceType,
protectionDeviceTypeLabels,
} from "../../shared/constants/protection-device";
import { orderDistributionBoardsByFeed } from "../utils/distribution-board-hierarchy"; import { orderDistributionBoardsByFeed } from "../utils/distribution-board-hierarchy";
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeProtectionDeviceDto, CircuitTreeCircuitProtectionDeviceDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
DistributionBoardDto, DistributionBoardDto,
} from "../types"; } from "../types";
@ -140,11 +144,14 @@ export const formatFactor = (value: number) => kilowattFormat.format(value);
export const formatCurrent = (value: number) => currentFormat.format(value); export const formatCurrent = (value: number) => currentFormat.format(value);
export function formatProtectionDevice( export function formatProtectionDevice(
protection: CircuitTreeProtectionDeviceDto | undefined protection: CircuitTreeCircuitProtectionDeviceDto | undefined
): string | null { ): string | null {
if (!protection) { if (!protection) {
return null; return null;
} }
if (protection.type === noCircuitProtectionDeviceType) {
return noCircuitProtectionDeviceText;
}
const parts = [ const parts = [
protectionDeviceTypeLabels[protection.type], protectionDeviceTypeLabels[protection.type],
protection.tripCharacteristic, protection.tripCharacteristic,

View file

@ -7,6 +7,7 @@ import type {
import type { import type {
BreakerTripCharacteristic, BreakerTripCharacteristic,
FuseUtilizationCategory, FuseUtilizationCategory,
NoCircuitProtectionDeviceType,
ProtectionDeviceType, ProtectionDeviceType,
RcdType, RcdType,
} from "../shared/constants/protection-device"; } from "../shared/constants/protection-device";
@ -517,6 +518,16 @@ export interface CircuitTreeProtectionDeviceDto {
ratedResidualCurrentMa?: number; ratedResidualCurrentMa?: number;
} }
// Explicit "no protection device" on a circuit; shown as "–". A circuit
// without protectionDevice is still open for planning.
export interface CircuitTreeNoProtectionDeviceDto {
type: NoCircuitProtectionDeviceType;
}
export type CircuitTreeCircuitProtectionDeviceDto =
| CircuitTreeProtectionDeviceDto
| CircuitTreeNoProtectionDeviceDto;
export interface CircuitTreeComponentDto { export interface CircuitTreeComponentDto {
id: string; id: string;
circuitListId: string; circuitListId: string;
@ -551,7 +562,7 @@ export interface CircuitTreeCircuitDto {
circuitCosPhi: number; circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRowDto[]; deviceRows: CircuitTreeDeviceRowDto[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoadDto[]; downstreamBoardLoads: CircuitTreeDownstreamBoardLoadDto[];
protectionDevice?: CircuitTreeProtectionDeviceDto; protectionDevice?: CircuitTreeCircuitProtectionDeviceDto;
} }
export interface CircuitTreeSectionDto { export interface CircuitTreeSectionDto {

View file

@ -1,7 +1,11 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js"; import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js"; import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js"; import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device"; import {
noCircuitProtectionDeviceText,
noCircuitProtectionDeviceType,
protectionDeviceTypeLabels,
} from "../../shared/constants/protection-device";
export type CellKey = export type CellKey =
| "equipmentIdentifier" | "equipmentIdentifier"
@ -531,7 +535,12 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
} }
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue { export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
const protection = circuit.protectionDevice; const hasNoProtectionDevice =
circuit.protectionDevice?.type === noCircuitProtectionDeviceType;
const protection =
circuit.protectionDevice?.type === noCircuitProtectionDeviceType
? undefined
: circuit.protectionDevice;
const protectionCharacteristic = protection const protectionCharacteristic = protection
? [ ? [
protection.fuseUtilizationCategory, protection.fuseUtilizationCategory,
@ -549,6 +558,9 @@ export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): G
case "displayName": return circuit.displayName; case "displayName": return circuit.displayName;
case "circuitTotalPower": return circuit.circuitTotalPower; case "circuitTotalPower": return circuit.circuitTotalPower;
case "protectionType": case "protectionType":
if (hasNoProtectionDevice) {
return noCircuitProtectionDeviceText;
}
return protection return protection
? protectionDeviceTypeLabels[protection.type] ? protectionDeviceTypeLabels[protection.type]
: undefined; : undefined;
@ -557,6 +569,9 @@ export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): G
case "protectionCharacteristic": case "protectionCharacteristic":
return protectionCharacteristic || undefined; return protectionCharacteristic || undefined;
case "protectionSummary": { case "protectionSummary": {
if (hasNoProtectionDevice) {
return noCircuitProtectionDeviceText;
}
if (!protection) { if (!protection) {
return undefined; return undefined;
} }

View file

@ -22,6 +22,7 @@ import type {
import { import {
toDistributionBoardComponentSnapshot, toDistributionBoardComponentSnapshot,
} from "./distribution-board-component-editing"; } from "./distribution-board-component-editing";
import { toCircuitProtectionSnapshot } from "./circuit-protection-editing";
export function toCircuitGroupSnapshot( export function toCircuitGroupSnapshot(
section: CircuitTreeSectionDto, section: CircuitTreeSectionDto,
@ -159,21 +160,7 @@ export function buildCircuitGroupSubtreeSnapshot(
})), })),
}, },
protectionDevice: circuit.protectionDevice protectionDevice: circuit.protectionDevice
? { ? toCircuitProtectionSnapshot(circuit.id, circuit.protectionDevice)
circuitId: circuit.id,
type: circuit.protectionDevice.type,
ratedCurrentA:
circuit.protectionDevice.ratedCurrentA,
fuseUtilizationCategory:
circuit.protectionDevice.fuseUtilizationCategory ??
null,
tripCharacteristic:
circuit.protectionDevice.tripCharacteristic ?? null,
rcdType: circuit.protectionDevice.rcdType ?? null,
ratedResidualCurrentMa:
circuit.protectionDevice.ratedResidualCurrentMa ??
null,
}
: null, : null,
})); }));
return { return {

View file

@ -3,14 +3,26 @@ import type {
} from "../../domain/models/circuit-protection-project-command.model"; } from "../../domain/models/circuit-protection-project-command.model";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults"; import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group"; import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
import { noCircuitProtectionDeviceType } from "../../shared/constants/protection-device";
import type { import type {
CircuitTreeProtectionDeviceDto, CircuitTreeCircuitProtectionDeviceDto,
} from "../types"; } from "../types";
export function toCircuitProtectionSnapshot( export function toCircuitProtectionSnapshot(
circuitId: string, circuitId: string,
protection: CircuitTreeProtectionDeviceDto protection: CircuitTreeCircuitProtectionDeviceDto
): CircuitProtectionSnapshot { ): CircuitProtectionSnapshot {
if (protection.type === noCircuitProtectionDeviceType) {
return {
circuitId,
type: noCircuitProtectionDeviceType,
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
};
}
return { return {
circuitId, circuitId,
type: protection.type, type: protection.type,
@ -26,8 +38,8 @@ export function toCircuitProtectionSnapshot(
export function getCircuitProtectionEditorInitialValue( export function getCircuitProtectionEditorInitialValue(
category: CircuitGroupCategory | undefined, category: CircuitGroupCategory | undefined,
protection: CircuitTreeProtectionDeviceDto | undefined protection: CircuitTreeCircuitProtectionDeviceDto | undefined
): CircuitTreeProtectionDeviceDto { ): CircuitTreeCircuitProtectionDeviceDto {
if (protection) { if (protection) {
return protection; return protection;
} }

View file

@ -2,6 +2,7 @@ import type { Request, Response } from "express";
import { calculateDistributionBoardLoads } from "../../domain/calculations/distribution-board-load-calculation.js"; import { calculateDistributionBoardLoads } from "../../domain/calculations/distribution-board-load-calculation.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js"; import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type { import type {
CircuitTreeCircuitProtectionDevice,
CircuitTreeProtectionDevice, CircuitTreeProtectionDevice,
CircuitTreeResponse, CircuitTreeResponse,
ProjectCircuitTreesResponse, ProjectCircuitTreesResponse,
@ -17,6 +18,7 @@ import {
externalModelStateRepository, externalModelStateRepository,
projectRepository, projectRepository,
} from "../composition/application-repositories.js"; } from "../composition/application-repositories.js";
import { noCircuitProtectionDeviceType } from "../../shared/constants/protection-device.js";
export function isMissingCircuitTreeSchemaError(error: unknown): boolean { export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
if (!(error instanceof Error)) return false; if (!(error instanceof Error)) return false;
@ -289,7 +291,9 @@ export async function buildProjectCircuitTrees(
"de" "de"
) )
), ),
protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined, protectionDevice: protection
? toCircuitProtectionDeviceDto(protection)
: undefined,
}); });
} }
for (const section of tree.sections) { for (const section of tree.sections) {
@ -358,6 +362,25 @@ function emptyCircuitTree(
}; };
} }
function toCircuitProtectionDeviceDto(
value: Omit<Parameters<typeof toProtectionDeviceDto>[0], "type" | "ratedCurrentA"> & {
type: CircuitTreeCircuitProtectionDevice["type"];
ratedCurrentA: number | null;
}
): CircuitTreeCircuitProtectionDevice {
if (value.type === noCircuitProtectionDeviceType) {
return { type: value.type };
}
if (value.ratedCurrentA === null) {
throw new Error("Circuit protection device lacks a rated current.");
}
return toProtectionDeviceDto({
...value,
type: value.type,
ratedCurrentA: value.ratedCurrentA,
});
}
function toProtectionDeviceDto(value: { function toProtectionDeviceDto(value: {
type: CircuitTreeProtectionDevice["type"]; type: CircuitTreeProtectionDevice["type"];
ratedCurrentA: number; ratedCurrentA: number;

View file

@ -26,6 +26,24 @@ export const protectionDeviceTypes = [
export type ProtectionDeviceType = (typeof protectionDeviceTypes)[number]; export type ProtectionDeviceType = (typeof protectionDeviceTypes)[number];
// A circuit may explicitly have no protection device ("–"). This differs from
// a circuit without any protection record, which is still open for planning.
// Distribution-board components always carry a real protection device.
export const noCircuitProtectionDeviceType = "NONE" as const;
export type NoCircuitProtectionDeviceType =
typeof noCircuitProtectionDeviceType;
// Display text of the explicit "no protection device" state.
export const noCircuitProtectionDeviceText = "–";
export const circuitProtectionDeviceTypes = [
...protectionDeviceTypes,
noCircuitProtectionDeviceType,
] as const;
export type CircuitProtectionDeviceType =
(typeof circuitProtectionDeviceTypes)[number];
export const fuseUtilizationCategories = ["gG", "gR"] as const; export const fuseUtilizationCategories = ["gG", "gR"] as const;
export type FuseUtilizationCategory = export type FuseUtilizationCategory =
(typeof fuseUtilizationCategories)[number]; (typeof fuseUtilizationCategories)[number];
@ -91,6 +109,14 @@ export const protectionDeviceTypeLabels: Record<ProtectionDeviceType, string> =
AFDD: "AFDD", AFDD: "AFDD",
}; };
export const circuitProtectionDeviceTypeLabels: Record<
CircuitProtectionDeviceType,
string
> = {
...protectionDeviceTypeLabels,
NONE: "Kein Schutzgerät",
};
export function isAllowedRatedCurrentA( export function isAllowedRatedCurrentA(
type: ProtectionDeviceType, type: ProtectionDeviceType,
ratedCurrentA: number ratedCurrentA: number

View file

@ -5,6 +5,7 @@ import {
fuseUtilizationCategories, fuseUtilizationCategories,
isAllowedRatedCurrentA, isAllowedRatedCurrentA,
isAllowedRatedResidualCurrentMa, isAllowedRatedResidualCurrentMa,
noCircuitProtectionDeviceType,
protectionDeviceTypes, protectionDeviceTypes,
rcdTypes, rcdTypes,
} from "../constants/protection-device.js"; } from "../constants/protection-device.js";
@ -93,3 +94,40 @@ export const protectionDeviceConfigurationSchema = z
export type ProtectionDeviceConfigurationInput = z.infer< export type ProtectionDeviceConfigurationInput = z.infer<
typeof protectionDeviceConfigurationSchema typeof protectionDeviceConfigurationSchema
>; >;
// Persisted circuit protection record: nullable columns instead of optional
// fields. Either the explicit "no protection device" state with every value
// null, or one valid protection device configuration.
export function isValidCircuitProtectionRecord(value: Readonly<
Record<string, unknown>
>): boolean {
if (value.type === noCircuitProtectionDeviceType) {
return (
value.ratedCurrentA === null &&
value.fuseUtilizationCategory === null &&
value.tripCharacteristic === null &&
value.rcdType === null &&
value.ratedResidualCurrentMa === null
);
}
return isValidProtectionRecord(value);
}
export function isValidProtectionRecord(value: Readonly<
Record<string, unknown>
>): boolean {
return protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
}).success;
}

View file

@ -160,6 +160,47 @@ describe("circuit grid clipboard", () => {
); );
}); });
it("copies an explicit no-protection decision onto other circuits", () => {
const none = makeCircuit("circuit-5", [makeDevice("row-5", 0.1)], {
protectionDevice: { type: "NONE" },
});
const noneRows = buildVisibleGridRowsWithStructure(
[{ ...sections[0], circuits: [none, target] }],
{ headerComponents: [], footerComponents: [] }
);
const [noneRow, targetRow] = ["circuitCompact:circuit-5", "circuitCompact:circuit-2"].map(
(rowKey) => noneRows.find((row) => row.rowKey === rowKey)!
);
const clipboard = buildCellClipboard([noneRow], ["protectionSummary"]);
assert.equal(clipboard.text, "–");
const plan = planCellWrites([
{ row: targetRow, cellKey: "protectionSummary", value: clipboard.cells[0][0] },
]);
assert.deepEqual(plan.updates.map((update) => update.payload), [
{
circuitId: "circuit-2",
expected: {
circuitId: "circuit-2",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
target: {
circuitId: "circuit-2",
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
},
},
]);
});
it("fills one value into every selected row and skips rows that cannot take it", () => { it("fills one value into every selected row and skips rows that cannot take it", () => {
const selectedRowKeys = [ const selectedRowKeys = [
"circuitCompact:circuit-1", "circuitCompact:circuit-1",

View file

@ -197,6 +197,18 @@ describe("circuit grid model", () => {
), ),
"FI/LS · 16 A · B · Typ A · 30 mA" "FI/LS · 16 A · B · Typ A · 30 mA"
); );
const withoutDevice = {
...circuit,
protectionDevice: { type: "NONE" as const },
};
assert.equal(getCircuitValue(withoutDevice, "protectionSummary"), "–");
assert.equal(getCircuitValue(withoutDevice, "protectionType"), "–");
assert.equal(getCircuitValue(withoutDevice, "protectionRatedCurrent"), undefined);
// A circuit without any protection record is still open.
assert.equal(
getCircuitValue({ ...circuit, protectionDevice: undefined }, "protectionSummary"),
undefined
);
assert.equal(getCircuitValue(circuit, "voltage"), 230); assert.equal(getCircuitValue(circuit, "voltage"), 230);
assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI"); assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI");
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m"); assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");

View file

@ -135,6 +135,70 @@ describe("circuit-protection project command", () => {
} }
}); });
it("stores an explicit no-protection decision and undoes it to the device", () => {
const context = createTestDatabase();
try {
const repository = new CircuitProtectionProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
context.db.insert(circuitProtectionDevices).values(protection).run();
const none: CircuitProtectionSnapshot = {
circuitId: "circuit-1",
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
};
const executed = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitProtectionUpdateProjectCommand(
"circuit-1",
protection,
none
),
});
assert.deepEqual(
context.db.select().from(circuitProtectionDevices).get(),
none
);
const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undo.changeSetId,
command: executed.inverse,
});
assert.deepEqual(
context.db.select().from(circuitProtectionDevices).get(),
protection
);
for (const invalid of [
{ ...none, ratedCurrentA: 16 },
{ ...none, tripCharacteristic: "B" as const },
{ ...protection, ratedCurrentA: null },
]) {
assert.throws(
() =>
createCircuitProtectionUpdateProjectCommand(
"circuit-1",
protection,
invalid
),
/configuration is invalid/
);
}
} finally {
context.close();
}
});
it("rejects stale state, foreign ownership and user removal", () => { it("rejects stale state, foreign ownership and user removal", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -243,6 +243,7 @@ describe("print document model", () => {
}), }),
"FI/LS B 16 A, 30 mA Typ A" "FI/LS B 16 A, 30 mA Typ A"
); );
assert.equal(formatProtectionDevice({ type: "NONE" }), "–");
assert.equal(formatProtectionDevice(undefined), null); assert.equal(formatProtectionDevice(undefined), null);
}); });
}); });

View file

@ -559,6 +559,44 @@ describe("project state snapshot model", () => {
() => parseProjectStateSnapshot(invalidProtection), () => parseProjectStateSnapshot(invalidProtection),
/protection-device configuration is invalid/ /protection-device configuration is invalid/
); );
const protectedState = protectedSnapshot();
const noProtectionRecord = {
circuitId: protectedState.circuitProtectionDevices[0]!.circuitId,
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
};
assert.equal(
parseProjectStateSnapshot({
...protectedState,
circuitProtectionDevices: [noProtectionRecord],
}).circuitProtectionDevices[0]?.type,
"NONE"
);
assert.throws(
() =>
parseProjectStateSnapshot({
...protectedState,
circuitProtectionDevices: [
{ ...noProtectionRecord, ratedCurrentA: 16 },
],
}),
/protection-device configuration is invalid/
);
const componentWithoutDevice = {
...protectedState,
distributionBoardComponentProtectionDevices: [
{
...protectedState.distributionBoardComponentProtectionDevices[0]!,
type: "NONE",
},
],
};
assert.throws(() => parseProjectStateSnapshot(componentWithoutDevice));
}); });
it("rejects duplicate ids and invalid ownership", () => { it("rejects duplicate ids and invalid ownership", () => {

View file

@ -205,6 +205,27 @@ describe("protection device defaults", () => {
); );
}); });
it("maps the explicit no-protection state to a record without values", () => {
const none = getCircuitProtectionEditorInitialValue("single_phase", {
type: "NONE",
});
assert.deepEqual(none, { type: "NONE" });
assert.deepEqual(toCircuitProtectionSnapshot("circuit-1", none), {
circuitId: "circuit-1",
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
});
// Distribution-board components never take the circuit-only state.
assert.equal(
protectionDeviceConfigurationSchema.safeParse({ type: "NONE" }).success,
false
);
});
it("creates the agreed circuit defaults", () => { it("creates the agreed circuit defaults", () => {
assert.deepEqual(createDefaultCircuitProtection("lighting"), { assert.deepEqual(createDefaultCircuitProtection("lighting"), {
type: "LS", type: "LS",