Add external object new circuit command

This commit is contained in:
2026-08-02 18:55:57 +02:00
parent 347b3717c3
commit a69b7b603f
13 changed files with 880 additions and 228 deletions
@@ -0,0 +1,168 @@
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import {
assertCircuitInsertProjectCommand,
circuitInsertCommandType,
circuitStructureCommandSchemaVersion,
type CircuitSnapshot,
} from "./circuit-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
export const externalObjectAssignToNewCircuitCommandType =
"external-object.assign-to-new-circuit" as const;
export const externalObjectDeleteCreatedCircuitCommandType =
"external-object.unassign-and-delete-created-circuit" as const;
export const externalObjectNewCircuitCommandSchemaVersion = 1 as const;
export interface ExternalObjectNewCircuitPayload {
circuit: CircuitSnapshot;
objects: Array<{
expected: ExternalModelObjectSnapshot;
target: ExternalModelObjectSnapshot;
}>;
confirmedConflictObjectIds: string[];
}
export interface ExternalObjectNewCircuitProjectCommand
extends SerializedProjectCommand<ExternalObjectNewCircuitPayload> {
schemaVersion: typeof externalObjectNewCircuitCommandSchemaVersion;
type:
| typeof externalObjectAssignToNewCircuitCommandType
| typeof externalObjectDeleteCreatedCircuitCommandType;
}
export function createExternalObjectNewCircuitProjectCommand(
type: ExternalObjectNewCircuitProjectCommand["type"],
payload: ExternalObjectNewCircuitPayload
): ExternalObjectNewCircuitProjectCommand {
const row = payload.circuit.deviceRows[0];
const command: ExternalObjectNewCircuitProjectCommand = {
schemaVersion: externalObjectNewCircuitCommandSchemaVersion,
type,
payload: {
circuit: row
? {
...payload.circuit,
deviceRows: [{
...row,
manualQuantity: row.manualQuantity ?? row.quantity,
}],
}
: payload.circuit,
objects: payload.objects,
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
},
};
assertExternalObjectNewCircuitProjectCommand(command);
return command;
}
export function invertExternalObjectNewCircuitProjectCommand(
command: ExternalObjectNewCircuitProjectCommand
) {
return createExternalObjectNewCircuitProjectCommand(
command.type === externalObjectAssignToNewCircuitCommandType
? externalObjectDeleteCreatedCircuitCommandType
: externalObjectAssignToNewCircuitCommandType,
{
circuit: command.payload.circuit,
objects: command.payload.objects.map(({ expected, target }) => ({
expected: target,
target: expected,
})),
confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds,
}
);
}
export function assertExternalObjectNewCircuitProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalObjectNewCircuitProjectCommand {
if (
command.schemaVersion !== externalObjectNewCircuitCommandSchemaVersion ||
(command.type !== externalObjectAssignToNewCircuitCommandType &&
command.type !== externalObjectDeleteCreatedCircuitCommandType) ||
!isRecord(command.payload) ||
!Array.isArray(command.payload.objects) ||
command.payload.objects.length === 0 ||
!Array.isArray(command.payload.confirmedConflictObjectIds)
) {
throw new Error("Unsupported external object new-circuit command.");
}
assertCircuitInsertProjectCommand({
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
payload: { circuit: command.payload.circuit },
});
const circuit = command.payload.circuit as CircuitSnapshot;
if (
circuit.deviceRows.length !== 1 ||
circuit.deviceRows[0]!.manualQuantity !== 0 ||
circuit.protectionDevice === undefined ||
circuit.protectionDevice === null
) {
throw new Error(
"An external circuit must contain one external-only row and protection device."
);
}
const rowId = circuit.deviceRows[0]!.id;
const objectIds = new Set<string>();
for (const transition of command.payload.objects) {
if (!isRecord(transition)) throw new Error("Invalid external object transition.");
const expected = parseObject(transition.expected);
const target = parseObject(transition.target);
if (expected.id !== target.id || objectIds.has(expected.id)) {
throw new Error("New-circuit assignment contains mismatched or duplicate objects.");
}
objectIds.add(expected.id);
const assigning = command.type === externalObjectAssignToNewCircuitCommandType;
if (
(assigning && (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== rowId)) ||
(!assigning && (expected.circuitDeviceRowId !== rowId || target.circuitDeviceRowId !== null))
) {
throw new Error("External object links do not match the new-circuit action.");
}
assertOnlyAssignmentChanged(expected, target);
}
const confirmedIds = new Set<string>();
for (const objectId of command.payload.confirmedConflictObjectIds) {
if (typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId)) {
throw new Error("New-circuit assignment contains an invalid conflict confirmation.");
}
confirmedIds.add(objectId);
}
}
function parseObject(value: unknown): ExternalModelObjectSnapshot {
return parseExternalModelStateSnapshot({
source: null,
importBatches: [],
roomMappings: [],
objects: [value],
}).objects[0]!;
}
function assertOnlyAssignmentChanged(
expected: ExternalModelObjectSnapshot,
target: ExternalModelObjectSnapshot
) {
const expectedRest = { ...expected, circuitDeviceRowId: null };
const targetRest = { ...target, circuitDeviceRowId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error("New-circuit assignment may only change the external object row link.");
}
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isRecord(value)) {
return `{${Object.keys(value).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}