Add pure Revit assignment planning with room-wise socket split

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 21:21:36 +01:00
parent e48042bde3
commit d4c44001e1
2 changed files with 826 additions and 0 deletions

View file

@ -0,0 +1,560 @@
/**
* Pure planning of Revit-object assignments for the circuit-list editor
* (requirements §11.4–11.7). The assignment dialog builds a local plan with
* it, and the API re-checks the submitted plan with the same rules.
*
* A Revit object is indivisible: it is planned onto exactly one row and so
* belongs to exactly one circuit. Objects are grouped into rows by room,
* selection marker, device type, category, project-device link and identical
* planning values; the effective quantity is not part of the group key, so
* single and multiple sockets share one row.
*
* This file has no imports because Turbopack resolves frontend imports
* without the `.js` suffix the API build needs.
*/
export type AssignmentPlanCategory = "lighting" | "single_phase" | "three_phase";
export interface AssignmentPlanObject {
id: string;
worksharingId: string;
familyAndType: string;
selectionMarker: string;
deviceTypeId: string;
roomId: string | null;
roomNumber: string;
roomName: string;
linkedProjectDeviceId: string | null;
isSocket: boolean;
planningValues: {
displayName: string | null;
category: AssignmentPlanCategory | null;
connectionKind: string | null;
effectiveQuantity: number;
powerPerUnitW: number | null;
simultaneityFactor: number | null;
cosPhi: number | null;
costGroup: string | null;
remark: string | null;
};
}
export interface AssignmentPlanRow {
id: string;
circuitId: string;
roomId: string | null;
displayName: string;
linkedProjectDeviceId: string | null;
category: string | null;
connectionKind: string | null;
/** kW per unit, as stored on CircuitDeviceRow. */
powerPerUnit: number;
simultaneityFactor: number;
cosPhi: number | null;
costGroup: string | null;
remark: string | null;
/** Distinct selection markers of the Revit objects already linked. */
linkedSelectionMarkers: string[];
linkedObjectCount: number;
}
export interface AssignmentPlanCircuit {
id: string;
sectionId: string;
category: AssignmentPlanCategory | null;
equipmentIdentifier: string;
displayName: string | null;
rows: AssignmentPlanRow[];
/** Effective quantity of linked socket objects before the plan. */
linkedSocketQuantity: number;
}
export interface AssignmentPlanSection {
id: string;
category: AssignmentPlanCategory | null;
}
export type AssignmentTarget =
| { kind: "row"; rowId: string }
| { kind: "circuit"; circuitId: string }
| { kind: "new-circuit"; key: string };
export interface AssignmentPlanState {
newCircuits: Array<{ key: string; sectionId: string }>;
assignments: Array<{ objectId: string; target: AssignmentTarget }>;
/** `newRowChoiceKey` values whose group gets a new row instead of a matching one. */
newRowChoices: string[];
}
export type AssignmentCircuitRef =
| { kind: "existing"; circuitId: string }
| { kind: "new"; key: string };
export type AssignmentConflictField =
| "displayName"
| "projectDevice"
| "connectionKind"
| "powerPerUnit"
| "simultaneityFactor"
| "cosPhi"
| "costGroup"
| "remark";
export interface AssignmentConflict {
objectId: string;
field: AssignmentConflictField;
rowValue: string | number | null;
objectValue: string | number | null;
}
export interface ResolvedRowAssignment {
key: string;
circuit: AssignmentCircuitRef;
row: { kind: "existing"; rowId: string } | { kind: "new"; groupKey: string };
/** For a group placed by circuit drop: the matching row it could join. */
matchingRowId: string | null;
objectIds: string[];
quantity: number;
socketQuantity: number;
conflicts: AssignmentConflict[];
issues: string[];
}
export interface ResolvedCircuitPlan {
circuit: AssignmentCircuitRef;
refKey: string;
sectionId: string;
socketQuantityBefore: number;
socketQuantityAfter: number;
overLimit: boolean;
rows: ResolvedRowAssignment[];
}
export interface ResolvedAssignmentPlan {
circuits: ResolvedCircuitPlan[];
issues: string[];
conflictObjectIds: string[];
overLimitRefKeys: string[];
objectCount: number;
newCircuitCount: number;
}
/** Request rows the API accepts for `external-object.apply-assignment-plan`. */
export type AssignmentPlanRequestRow =
| { kind: "existing-row"; rowId: string; objectIds: string[] }
| { kind: "new-row"; circuitId: string; objectIds: string[] }
| { kind: "new-row-in-new-circuit"; newCircuitKey: string; objectIds: string[] };
export const assignmentConflictFieldLabels: Record<AssignmentConflictField, string> = {
displayName: "Bezeichnung",
projectDevice: "Projektgerät",
connectionKind: "Anschlussart",
powerPerUnit: "Leistung je Einheit",
simultaneityFactor: "GZF",
cosPhi: "cos φ",
costGroup: "Kostengruppe",
remark: "Bemerkung",
};
export function circuitRefKey(ref: AssignmentCircuitRef): string {
return ref.kind === "existing" ? ref.circuitId : `new:${ref.key}`;
}
export function objectGroupKey(object: AssignmentPlanObject): string {
const planning = object.planningValues;
return JSON.stringify([
object.roomId,
object.selectionMarker.trim(),
object.deviceTypeId,
planning.category,
object.linkedProjectDeviceId,
planning.displayName,
planning.connectionKind,
planning.powerPerUnitW,
planning.simultaneityFactor,
planning.cosPhi,
planning.costGroup,
planning.remark,
]);
}
export function newRowChoiceKey(ref: AssignmentCircuitRef, groupKey: string) {
return `${circuitRefKey(ref)}|${groupKey}`;
}
export function socketQuantity(objects: readonly AssignmentPlanObject[]) {
return objects.reduce(
(sum, object) => sum + (object.isSocket ? object.planningValues.effectiveQuantity : 0),
0
);
}
export function effectiveQuantity(objects: readonly AssignmentPlanObject[]) {
return objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
}
/** Planning-value differences between an existing row and an object. */
export function findRowConflicts(
row: AssignmentPlanRow,
object: AssignmentPlanObject
): AssignmentConflict[] {
const planning = object.planningValues;
const conflicts: AssignmentConflict[] = [];
const add = (
field: AssignmentConflictField,
rowValue: string | number | null,
objectValue: string | number | null
) => conflicts.push({ objectId: object.id, field, rowValue, objectValue });
if (planning.displayName !== null && row.displayName !== planning.displayName) {
add("displayName", row.displayName, planning.displayName);
}
if (row.linkedProjectDeviceId !== object.linkedProjectDeviceId) {
add("projectDevice", row.linkedProjectDeviceId, object.linkedProjectDeviceId);
}
if (row.connectionKind !== planning.connectionKind) {
add("connectionKind", row.connectionKind, planning.connectionKind);
}
if (planning.powerPerUnitW !== null && row.powerPerUnit !== planning.powerPerUnitW / 1000) {
add("powerPerUnit", row.powerPerUnit * 1000, planning.powerPerUnitW);
}
if (row.simultaneityFactor !== (planning.simultaneityFactor ?? 1)) {
add("simultaneityFactor", row.simultaneityFactor, planning.simultaneityFactor ?? 1);
}
if (row.cosPhi !== planning.cosPhi) add("cosPhi", row.cosPhi, planning.cosPhi);
if (row.costGroup !== planning.costGroup) add("costGroup", row.costGroup, planning.costGroup);
if (row.remark !== planning.remark) add("remark", row.remark, planning.remark);
return conflicts;
}
/**
* Blocking reasons why objects cannot join a row: category of the target
* group, room and a single selection marker per row (server rules).
*/
export function findRowIssues(
row: AssignmentPlanRow,
circuitCategory: AssignmentPlanCategory | null,
objects: readonly AssignmentPlanObject[]
): string[] {
const issues = findCategoryIssues(circuitCategory, objects);
if (objects.some((object) => object.roomId !== row.roomId)) {
issues.push("Raum der Revit-Objekte passt nicht zur Gerätezeile.");
}
const markers = new Set([
...row.linkedSelectionMarkers.map((marker) => marker.trim()),
...objects.map((object) => object.selectionMarker.trim()),
]);
if (markers.size > 1) {
issues.push("Unterschiedliche Auswahlkenner benötigen getrennte Gerätezeilen.");
}
return issues;
}
export function findCategoryIssues(
circuitCategory: AssignmentPlanCategory | null,
objects: readonly AssignmentPlanObject[]
): string[] {
if (circuitCategory === null) {
return ["Die Stromkreisgruppe besitzt keine Kategorie."];
}
return objects.some((object) => object.planningValues.category !== circuitCategory)
? ["Kategorie der Revit-Objekte passt nicht zur Stromkreisgruppe."]
: [];
}
/**
* Matching row a group joins when dropped on its circuit: only rows that
* already carry Revit links, with the same room, category, project device
* and selection marker and without planning-value conflicts. Manual-only rows
* are never joined implicitly to avoid counting devices twice.
*/
export function findMatchingRow(
circuit: AssignmentPlanCircuit,
objects: readonly AssignmentPlanObject[]
): AssignmentPlanRow | null {
return (
circuit.rows.find(
(row) =>
row.linkedObjectCount > 0 &&
findRowIssues(row, circuit.category, objects).length === 0 &&
objects.every((object) => findRowConflicts(row, object).length === 0)
) ?? null
);
}
export function resolveAssignmentPlan(input: {
objects: readonly AssignmentPlanObject[];
circuits: readonly AssignmentPlanCircuit[];
sections: readonly AssignmentPlanSection[];
plan: AssignmentPlanState;
maxSocketsPerCircuit: number | null;
}): ResolvedAssignmentPlan {
const objectById = new Map(input.objects.map((object) => [object.id, object]));
const circuitById = new Map(input.circuits.map((circuit) => [circuit.id, circuit]));
const rowById = new Map(
input.circuits.flatMap((circuit) => circuit.rows.map((row) => [row.id, { row, circuit }] as const))
);
const sectionById = new Map(input.sections.map((section) => [section.id, section]));
const newCircuitByKey = new Map(input.plan.newCircuits.map((entry) => [entry.key, entry]));
const newRowChoices = new Set(input.plan.newRowChoices);
const issues: string[] = [];
interface Bucket {
ref: AssignmentCircuitRef;
sectionId: string;
category: AssignmentPlanCategory | null;
existing: AssignmentPlanCircuit | null;
rowTargets: Map<string, AssignmentPlanObject[]>;
circuitTargets: AssignmentPlanObject[];
}
const buckets = new Map<string, Bucket>();
const bucketFor = (ref: AssignmentCircuitRef): Bucket | null => {
const key = circuitRefKey(ref);
const known = buckets.get(key);
if (known) return known;
let bucket: Bucket;
if (ref.kind === "existing") {
const circuit = circuitById.get(ref.circuitId);
if (!circuit) return null;
bucket = {
ref, sectionId: circuit.sectionId, category: circuit.category, existing: circuit,
rowTargets: new Map(), circuitTargets: [],
};
} else {
const entry = newCircuitByKey.get(ref.key);
const section = entry ? sectionById.get(entry.sectionId) : undefined;
if (!entry || !section) return null;
bucket = {
ref, sectionId: section.id, category: section.category, existing: null,
rowTargets: new Map(), circuitTargets: [],
};
}
buckets.set(key, bucket);
return bucket;
};
const seen = new Set<string>();
for (const assignment of input.plan.assignments) {
const object = objectById.get(assignment.objectId);
if (!object) {
issues.push("Ein geplantes Revit-Objekt ist nicht mehr zuordenbar.");
continue;
}
if (seen.has(object.id)) {
issues.push(`Revit-Objekt ${object.worksharingId} ist mehrfach geplant.`);
continue;
}
seen.add(object.id);
const target = assignment.target;
if (target.kind === "row") {
const location = rowById.get(target.rowId);
const bucket = location
? bucketFor({ kind: "existing", circuitId: location.circuit.id })
: null;
if (!bucket) {
issues.push("Eine Zielzeile existiert nicht mehr.");
continue;
}
const entries = bucket.rowTargets.get(target.rowId) ?? [];
entries.push(object);
bucket.rowTargets.set(target.rowId, entries);
} else {
const bucket = bucketFor(
target.kind === "circuit"
? { kind: "existing", circuitId: target.circuitId }
: { kind: "new", key: target.key }
);
if (!bucket) {
issues.push("Ein Zielstromkreis existiert nicht mehr.");
continue;
}
bucket.circuitTargets.push(object);
}
}
const circuits: ResolvedCircuitPlan[] = [];
for (const bucket of buckets.values()) {
const rowAssignments: ResolvedRowAssignment[] = [];
const refKey = circuitRefKey(bucket.ref);
const joinedRows = new Map<string, AssignmentPlanObject[]>(bucket.rowTargets);
const newRowGroups = new Map<string, AssignmentPlanObject[]>();
const matchingByGroup = new Map<string, string | null>();
const groups = new Map<string, AssignmentPlanObject[]>();
for (const object of bucket.circuitTargets) {
const key = objectGroupKey(object);
const entries = groups.get(key) ?? [];
entries.push(object);
groups.set(key, entries);
}
for (const [groupKey, objects] of groups) {
const matching = bucket.existing ? findMatchingRow(bucket.existing, objects) : null;
matchingByGroup.set(groupKey, matching?.id ?? null);
if (matching && !newRowChoices.has(newRowChoiceKey(bucket.ref, groupKey))) {
joinedRows.set(matching.id, [...(joinedRows.get(matching.id) ?? []), ...objects]);
} else {
newRowGroups.set(groupKey, objects);
}
}
for (const [rowId, objects] of joinedRows) {
const row = rowById.get(rowId)!.row;
rowAssignments.push({
key: `${refKey}|row:${rowId}`,
circuit: bucket.ref,
row: { kind: "existing", rowId },
matchingRowId: null,
objectIds: objects.map((object) => object.id),
quantity: effectiveQuantity(objects),
socketQuantity: socketQuantity(objects),
conflicts: objects.flatMap((object) => findRowConflicts(row, object)),
issues: findRowIssues(row, bucket.category, objects),
});
}
for (const [groupKey, objects] of newRowGroups) {
rowAssignments.push({
key: `${refKey}|new:${groupKey}`,
circuit: bucket.ref,
row: { kind: "new", groupKey },
matchingRowId: matchingByGroup.get(groupKey) ?? null,
objectIds: objects.map((object) => object.id),
quantity: effectiveQuantity(objects),
socketQuantity: socketQuantity(objects),
conflicts: [],
issues: findCategoryIssues(bucket.category, objects),
});
}
const before = bucket.existing?.linkedSocketQuantity ?? 0;
const after = before + rowAssignments.reduce((sum, row) => sum + row.socketQuantity, 0);
const addsSockets = after > before;
circuits.push({
circuit: bucket.ref,
refKey,
sectionId: bucket.sectionId,
socketQuantityBefore: before,
socketQuantityAfter: after,
overLimit:
addsSockets &&
input.maxSocketsPerCircuit !== null &&
after > input.maxSocketsPerCircuit,
rows: rowAssignments,
});
}
const usedNewKeys = new Set(
circuits.flatMap((circuit) => (circuit.circuit.kind === "new" ? [circuit.circuit.key] : []))
);
return {
circuits,
issues: [...issues, ...circuits.flatMap((circuit) => circuit.rows.flatMap((row) => row.issues))],
conflictObjectIds: [
...new Set(
circuits.flatMap((circuit) =>
circuit.rows.flatMap((row) => row.conflicts.map((conflict) => conflict.objectId))
)
),
],
overLimitRefKeys: circuits.filter((circuit) => circuit.overLimit).map((circuit) => circuit.refKey),
objectCount: seen.size,
newCircuitCount: input.plan.newCircuits.filter((entry) => usedNewKeys.has(entry.key)).length,
};
}
/** Converts a resolved plan into the API request rows. */
export function toAssignmentPlanRequestRows(
plan: ResolvedAssignmentPlan
): AssignmentPlanRequestRow[] {
return plan.circuits.flatMap((circuit) =>
circuit.rows.map((row): AssignmentPlanRequestRow => {
if (row.row.kind === "existing") {
return { kind: "existing-row", rowId: row.row.rowId, objectIds: row.objectIds };
}
return circuit.circuit.kind === "existing"
? { kind: "new-row", circuitId: circuit.circuit.circuitId, objectIds: row.objectIds }
: { kind: "new-row-in-new-circuit", newCircuitKey: circuit.circuit.key, objectIds: row.objectIds };
})
);
}
export interface RoomwiseSplitResult {
/** Object ids per new circuit, in planning order. */
circuits: string[][];
/** Single objects larger than the limit; each got its own circuit. */
oversizedObjectIds: string[];
/** Selected objects that are not sockets and stay unplanned. */
skippedObjectIds: string[];
}
/**
* Distributes socket objects room by room onto new circuits without ever
* splitting an object (requirements §11.6):
* 1. rooms by room number (natural order), objects without room last,
* objects of a room by Worksharing-ID;
* 2. a room that fits the remaining capacity joins the current circuit;
* 3. otherwise a room that fits an empty circuit starts a new one;
* 4. otherwise the room is filled object by object onto fresh circuits.
*/
export function splitSocketsByRoom(
objects: readonly AssignmentPlanObject[],
maxSocketsPerCircuit: number
): RoomwiseSplitResult {
if (!Number.isInteger(maxSocketsPerCircuit) || maxSocketsPerCircuit < 1) {
throw new Error("Der Höchstwert muss eine positive ganze Zahl sein.");
}
const sockets = objects.filter((object) => object.isSocket);
const skippedObjectIds = objects.filter((object) => !object.isSocket).map((object) => object.id);
const rooms = new Map<string, AssignmentPlanObject[]>();
for (const object of sockets) {
const key = object.roomId ?? (object.roomNumber ? `source:${object.roomNumber}` : "");
const entries = rooms.get(key) ?? [];
entries.push(object);
rooms.set(key, entries);
}
const orderedRooms = [...rooms.entries()]
.sort(([leftKey, left], [rightKey, right]) => {
if (leftKey === "" || rightKey === "") return leftKey === "" ? (rightKey === "" ? 0 : 1) : -1;
return compareNatural(left[0]!.roomNumber, right[0]!.roomNumber) ||
compareNatural(left[0]!.roomName, right[0]!.roomName) ||
compareNatural(leftKey, rightKey);
})
.map(([, entries]) =>
[...entries].sort((left, right) => compareNatural(left.worksharingId, right.worksharingId))
);
const circuits: string[][] = [];
const oversizedObjectIds: string[] = [];
let current: AssignmentPlanObject[] = [];
let currentQuantity = 0;
const close = () => {
if (current.length > 0) circuits.push(current.map((object) => object.id));
current = [];
currentQuantity = 0;
};
for (const room of orderedRooms) {
const roomQuantity = socketQuantity(room);
if (current.length > 0 && currentQuantity + roomQuantity <= maxSocketsPerCircuit) {
current.push(...room);
currentQuantity += roomQuantity;
continue;
}
close();
if (roomQuantity <= maxSocketsPerCircuit) {
current = [...room];
currentQuantity = roomQuantity;
continue;
}
for (const object of room) {
const quantity = object.planningValues.effectiveQuantity;
if (current.length > 0 && currentQuantity + quantity > maxSocketsPerCircuit) close();
if (quantity > maxSocketsPerCircuit) oversizedObjectIds.push(object.id);
current.push(object);
currentQuantity += quantity;
}
}
close();
return { circuits, oversizedObjectIds, skippedObjectIds };
}
function compareNatural(left: string, right: string) {
return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" });
}

View file

@ -0,0 +1,266 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
circuitRefKey,
findRowConflicts,
newRowChoiceKey,
objectGroupKey,
resolveAssignmentPlan,
splitSocketsByRoom,
toAssignmentPlanRequestRows,
type AssignmentPlanCircuit,
type AssignmentPlanObject,
type AssignmentPlanRow,
} from "../src/shared/revit/assignment-plan.js";
function socket(
id: string,
overrides: Partial<AssignmentPlanObject> & { quantity?: number } = {}
): AssignmentPlanObject {
const { quantity = 2, ...rest } = overrides;
return {
id,
worksharingId: `ws-${id}`,
familyAndType: "Steckdose: Doppel",
selectionMarker: "SD",
deviceTypeId: "type-socket",
roomId: "room-1",
roomNumber: "1.01",
roomName: "Büro",
linkedProjectDeviceId: null,
isSocket: true,
planningValues: {
displayName: "Steckdose",
category: "single_phase",
connectionKind: null,
effectiveQuantity: quantity,
powerPerUnitW: 200,
simultaneityFactor: 1,
cosPhi: 0.95,
costGroup: "440",
remark: null,
},
...rest,
};
}
function row(overrides: Partial<AssignmentPlanRow> = {}): AssignmentPlanRow {
return {
id: "row-1",
circuitId: "circuit-1",
roomId: "room-1",
displayName: "Steckdose",
linkedProjectDeviceId: null,
category: "single_phase",
connectionKind: null,
powerPerUnit: 0.2,
simultaneityFactor: 1,
cosPhi: 0.95,
costGroup: "440",
remark: null,
linkedSelectionMarkers: ["SD"],
linkedObjectCount: 2,
...overrides,
};
}
function circuit(overrides: Partial<AssignmentPlanCircuit> = {}): AssignmentPlanCircuit {
return {
id: "circuit-1",
sectionId: "section-2",
category: "single_phase",
equipmentIdentifier: "-2F1",
displayName: "Steckdose",
rows: [row()],
linkedSocketQuantity: 4,
...overrides,
};
}
const sections = [
{ id: "section-1", category: "lighting" as const },
{ id: "section-2", category: "single_phase" as const },
];
describe("Revit assignment plan", () => {
it("splits 8 double sockets with limit 8 into two circuits of 4 objects", () => {
const objects = Array.from({ length: 8 }, (_, index) => socket(`s${index + 1}`));
const result = splitSocketsByRoom(objects, 8);
assert.deepEqual(result.circuits.map((ids) => ids.length), [4, 4]);
assert.deepEqual(result.oversizedObjectIds, []);
assert.deepEqual(result.circuits.flat().sort(), objects.map((object) => object.id).sort());
});
it("keeps rooms together and only splits a room when it exceeds the limit", () => {
const objects = [
...[1, 2].map((index) => socket(`a${index}`, { roomId: "room-a", roomNumber: "1.02" })),
...[1, 2, 3].map((index) => socket(`b${index}`, { roomId: "room-b", roomNumber: "1.10" })),
...[1, 2].map((index) => socket(`c${index}`, { roomId: "room-c", roomNumber: "1.3" })),
...[1, 2, 3, 4, 5].map((index) => socket(`d${index}`, { roomId: "room-d", roomNumber: "2.01" })),
];
// Natural order 1.02 (4) · 1.3 (4) · 1.10 (6) · 2.01 (10)
const result = splitSocketsByRoom(objects, 8);
assert.deepEqual(result.circuits, [
["a1", "a2", "c1", "c2"],
["b1", "b2", "b3"],
["d1", "d2", "d3", "d4"],
["d5"],
]);
});
it("gives an oversized object its own circuit and skips non-sockets", () => {
const result = splitSocketsByRoom(
[
socket("big", { quantity: 10 }),
socket("small", { roomId: "room-2", roomNumber: "1.02" }),
socket("light", { isSocket: false }),
],
8
);
assert.deepEqual(result.circuits, [["big"], ["small"]]);
assert.deepEqual(result.oversizedObjectIds, ["big"]);
assert.deepEqual(result.skippedObjectIds, ["light"]);
assert.throws(() => splitSocketsByRoom([], 0), /positive ganze Zahl/);
});
it("groups single and double sockets of a room into one row and separates rooms", () => {
const single = socket("single", { quantity: 1 });
assert.equal(objectGroupKey(single), objectGroupKey(socket("double")));
const plan = resolveAssignmentPlan({
objects: [single, socket("double"), socket("other-room", { roomId: "room-2" })],
circuits: [],
sections,
plan: {
newCircuits: [{ key: "n1", sectionId: "section-2" }],
assignments: ["single", "double", "other-room"].map((objectId) => ({
objectId,
target: { kind: "new-circuit" as const, key: "n1" },
})),
newRowChoices: [],
},
maxSocketsPerCircuit: 8,
});
assert.equal(plan.circuits.length, 1);
assert.deepEqual(plan.circuits[0]!.rows.map((entry) => entry.quantity), [3, 2]);
assert.equal(plan.circuits[0]!.socketQuantityAfter, 5);
assert.equal(plan.newCircuitCount, 1);
assert.deepEqual(plan.issues, []);
assert.deepEqual(toAssignmentPlanRequestRows(plan).map((entry) => entry.kind), [
"new-row-in-new-circuit",
"new-row-in-new-circuit",
]);
});
it("joins a matching linked row on circuit drop unless a new row is chosen", () => {
const input = {
objects: [socket("s1")],
circuits: [circuit()],
sections,
maxSocketsPerCircuit: 8,
};
const joined = resolveAssignmentPlan({
...input,
plan: {
newCircuits: [],
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
newRowChoices: [],
},
});
assert.deepEqual(joined.circuits[0]!.rows[0]!.row, { kind: "existing", rowId: "row-1" });
assert.equal(joined.circuits[0]!.socketQuantityAfter, 6);
const separate = resolveAssignmentPlan({
...input,
plan: {
newCircuits: [],
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
newRowChoices: [
newRowChoiceKey({ kind: "existing", circuitId: "circuit-1" }, objectGroupKey(socket("s1"))),
],
},
});
assert.equal(separate.circuits[0]!.rows[0]!.row.kind, "new");
assert.equal(separate.circuits[0]!.rows[0]!.matchingRowId, "row-1");
const manualOnly = resolveAssignmentPlan({
...input,
circuits: [circuit({ rows: [row({ linkedObjectCount: 0, linkedSelectionMarkers: [] })] })],
plan: {
newCircuits: [],
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
newRowChoices: [],
},
});
assert.equal(manualOnly.circuits[0]!.rows[0]!.row.kind, "new");
});
it("lists planning conflicts and blocking issues for an explicit row drop", () => {
const differing = socket("s1", {
planningValues: { ...socket("x").planningValues, powerPerUnitW: 250, simultaneityFactor: null },
});
assert.deepEqual(
findRowConflicts(row({ simultaneityFactor: 0.5 }), differing).map((entry) => entry.field),
["powerPerUnit", "simultaneityFactor"]
);
const plan = resolveAssignmentPlan({
objects: [differing, socket("s2", { roomId: "room-2", selectionMarker: "X" })],
circuits: [circuit()],
sections,
plan: {
newCircuits: [],
assignments: [
{ objectId: "s1", target: { kind: "row", rowId: "row-1" } },
{ objectId: "s2", target: { kind: "row", rowId: "row-1" } },
],
newRowChoices: [],
},
maxSocketsPerCircuit: null,
});
assert.deepEqual(plan.conflictObjectIds, ["s1"]);
assert.ok(plan.issues.some((issue) => /Raum/.test(issue)));
assert.ok(plan.issues.some((issue) => /Auswahlkenner/.test(issue)));
assert.equal(plan.circuits[0]!.overLimit, false);
});
it("marks only circuits that gain sockets above the limit and rejects wrong categories", () => {
const plan = resolveAssignmentPlan({
objects: [socket("s1"), socket("s2"), socket("s3", { quantity: 4 })],
circuits: [circuit()],
sections,
plan: {
newCircuits: [{ key: "light", sectionId: "section-1" }],
assignments: [
{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } },
{ objectId: "s2", target: { kind: "circuit", circuitId: "circuit-1" } },
{ objectId: "s3", target: { kind: "new-circuit", key: "light" } },
],
newRowChoices: [],
},
maxSocketsPerCircuit: 6,
});
assert.deepEqual(plan.overLimitRefKeys, ["circuit-1"]);
assert.equal(plan.circuits[0]!.socketQuantityAfter, 8);
assert.equal(circuitRefKey(plan.circuits[1]!.circuit), "new:light");
assert.ok(plan.issues.some((issue) => /Kategorie/.test(issue)));
});
it("reports duplicate and unknown objects", () => {
const plan = resolveAssignmentPlan({
objects: [socket("s1")],
circuits: [],
sections,
plan: {
newCircuits: [{ key: "n1", sectionId: "section-2" }],
assignments: [
{ objectId: "s1", target: { kind: "new-circuit", key: "n1" } },
{ objectId: "s1", target: { kind: "new-circuit", key: "n1" } },
{ objectId: "gone", target: { kind: "new-circuit", key: "n1" } },
],
newRowChoices: [],
},
maxSocketsPerCircuit: 8,
});
assert.equal(plan.objectCount, 1);
assert.equal(plan.issues.length, 2);
});
});