From d4c44001e1c4952b7b45f6d9e5a4e42c0c8b2bdd Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 30 Oct 2026 21:21:36 +0100 Subject: [PATCH] Add pure Revit assignment planning with room-wise socket split Co-Authored-By: Claude Opus 5.5 --- src/shared/revit/assignment-plan.ts | 560 ++++++++++++++++++++++++++++ tests/revit-assignment-plan.test.ts | 266 +++++++++++++ 2 files changed, 826 insertions(+) create mode 100644 src/shared/revit/assignment-plan.ts create mode 100644 tests/revit-assignment-plan.test.ts diff --git a/src/shared/revit/assignment-plan.ts b/src/shared/revit/assignment-plan.ts new file mode 100644 index 0000000..62884d7 --- /dev/null +++ b/src/shared/revit/assignment-plan.ts @@ -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 = { + 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; + circuitTargets: AssignmentPlanObject[]; + } + const buckets = new Map(); + 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(); + 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(bucket.rowTargets); + const newRowGroups = new Map(); + const matchingByGroup = new Map(); + + const groups = new Map(); + 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(); + 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" }); +} diff --git a/tests/revit-assignment-plan.test.ts b/tests/revit-assignment-plan.test.ts new file mode 100644 index 0000000..25c7b32 --- /dev/null +++ b/tests/revit-assignment-plan.test.ts @@ -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 & { 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 { + 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 { + 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); + }); +});