From 2ae4d12fb11b546c7853d9ec518446b92cb45c72 Mon Sep 17 00:00:00 2001 From: Laurent Haan Date: Tue, 12 May 2026 14:45:39 +0200 Subject: [PATCH 1/3] Extend reflexive association limit from 2 to 4 Adds lower-right (index 2) and lower-left (index 3) positions, arranged clockwise from the existing upper-left and upper-right. Lower loops mirror the upper geometry vertically, extending downward from the bottom edge of the node. Label transforms, resize handle drag direction, and side assignment logic are all updated accordingly. The import limit and the limit dialog copy are updated to reflect four. --- .../flow/edges/ReflexiveAssociation.jsx | 41 ++++++++-------- .../reflexiveAssociationUtils.test.js | 47 ++++++++++++++++++ .../flow/utils/reflexiveAssociationUtils.js | 48 +++++++++++-------- src/hooks/useModelState.js | 6 ++- src/model/__tests__/edgeUtils.test.js | 10 +++- src/model/dialogCopy.js | 2 +- src/model/edgeUtils.js | 24 +++++++--- src/model/javaModelizerImport.js | 2 +- 8 files changed, 130 insertions(+), 50 deletions(-) diff --git a/src/components/flow/edges/ReflexiveAssociation.jsx b/src/components/flow/edges/ReflexiveAssociation.jsx index fff562e..062d7eb 100644 --- a/src/components/flow/edges/ReflexiveAssociation.jsx +++ b/src/components/flow/edges/ReflexiveAssociation.jsx @@ -5,25 +5,25 @@ import { RoleLabel } from '../labels/RoleLabel.jsx' import { getReflexiveAssociationLayout } from '../utils/reflexiveAssociationUtils.js' import { ReflexiveResizeHandles } from './ReflexiveResizeHandles.jsx' -function getStartMultiplicityTransform(side, x, y) { - if (side === 'right') { - return `translate(0%, -100%) translate(${x}px, ${y - 1}px)` - } - return `translate(-100%, -100%) translate(${x}px, ${y - 1}px)` +function getStartMultiplicityTransform(isRight, x, y, isLower) { + const xTranslate = isRight ? '0%' : '-100%' + const yTranslate = isLower ? '0%' : '-100%' + const yOffset = isLower ? y + 1 : y - 1 + return `translate(${xTranslate}, ${yTranslate}) translate(${x}px, ${yOffset}px)` } -function getEndMultiplicityTransform(side, x, y) { - if (side === 'right') { - return `translate(0%, -100%) translate(${x + 1}px, ${y}px)` - } - return `translate(-100%, -100%) translate(${x - 1}px, ${y}px)` +function getEndMultiplicityTransform(isRight, x, y, isLower) { + const xTranslate = isRight ? '0%' : '-100%' + const yTranslate = isLower ? '0%' : '-100%' + const xOffset = isRight ? x + 1 : x - 1 + return `translate(${xTranslate}, ${yTranslate}) translate(${xOffset}px, ${y}px)` } -function getStartRoleTransform(side, x, y) { - if (side === 'right') { - return `translate(0%, 0%) translate(${x}px, ${y + 1}px)` - } - return `translate(-100%, 0%) translate(${x}px, ${y + 1}px)` +function getStartRoleTransform(isRight, x, y, isLower) { + const xTranslate = isRight ? '0%' : '-100%' + const yTranslate = isLower ? '-100%' : '0%' + const yOffset = isLower ? y - 1 : y + 1 + return `translate(${xTranslate}, ${yTranslate}) translate(${x}px, ${yOffset}px)` } export function ReflexiveAssociation({ @@ -41,7 +41,8 @@ export function ReflexiveAssociation({ } const side = layout.side - const isRight = side === 'right' + const isRight = side === 'right' || side === 'lower-right' + const isLower = layout.isLower const startX = layout.startAnchor.x const startY = layout.startAnchor.y const endX = layout.endAnchor.x @@ -89,25 +90,25 @@ export function ReflexiveAssociation({ {multiplicityA ? ( ) : null} {multiplicityB ? ( ) : null} {roleA ? ( ) : null} {roleB ? ( ) : null} diff --git a/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js b/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js index fc31d5a..f318b95 100644 --- a/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js +++ b/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js @@ -62,6 +62,14 @@ describe("getReflexiveSide", () => { expect(getReflexiveSide({ reflexiveSide: "left" })).toBe("left"); }); + it("returns 'lower-right' when reflexiveSide is 'lower-right'", () => { + expect(getReflexiveSide({ reflexiveSide: "lower-right" })).toBe("lower-right"); + }); + + it("returns 'lower-left' when reflexiveSide is 'lower-left'", () => { + expect(getReflexiveSide({ reflexiveSide: "lower-left" })).toBe("lower-left"); + }); + it("returns 'left' for reflexiveIndex 0", () => { expect(getReflexiveSide({ reflexiveIndex: 0 })).toBe("left"); }); @@ -70,6 +78,14 @@ describe("getReflexiveSide", () => { expect(getReflexiveSide({ reflexiveIndex: 1 })).toBe("right"); }); + it("returns 'lower-right' for reflexiveIndex 2", () => { + expect(getReflexiveSide({ reflexiveIndex: 2 })).toBe("lower-right"); + }); + + it("returns 'lower-left' for reflexiveIndex 3", () => { + expect(getReflexiveSide({ reflexiveIndex: 3 })).toBe("lower-left"); + }); + it("defaults to 'left' for missing/null data", () => { expect(getReflexiveSide(null)).toBe("left"); expect(getReflexiveSide(undefined)).toBe("left"); @@ -129,17 +145,48 @@ describe("getReflexiveAssociationLayout", () => { expect(layout).toHaveProperty("loopWidth"); expect(layout).toHaveProperty("loopHeight"); expect(layout).toHaveProperty("side"); + expect(layout).toHaveProperty("isLower"); expect(layout).toHaveProperty("resizeHandles"); }); it("produces a 'left' side layout by default", () => { const layout = getReflexiveAssociationLayout(makeNode(0, 0, 200, 100), {}); expect(layout.side).toBe("left"); + expect(layout.isLower).toBe(false); }); it("produces a 'right' side layout when reflexiveSide is right", () => { const layout = getReflexiveAssociationLayout(makeNode(0, 0, 200, 100), { reflexiveSide: "right" }); expect(layout.side).toBe("right"); + expect(layout.isLower).toBe(false); + }); + + it("produces a 'lower-right' layout with isLower=true", () => { + const node = makeNode(0, 0, 200, 100); + const layout = getReflexiveAssociationLayout(node, { reflexiveSide: "lower-right" }); + expect(layout.side).toBe("lower-right"); + expect(layout.isLower).toBe(true); + // startAnchor should be near the bottom of the node + expect(layout.startAnchor.y).toBeGreaterThan(node.internals.positionAbsolute.y + node.measured.height / 2); + // loop extends downward: outerEdgeY > startAnchor.y + expect(layout.outerEdgeY).toBeGreaterThan(layout.startAnchor.y); + }); + + it("produces a 'lower-left' layout with isLower=true", () => { + const node = makeNode(0, 0, 200, 100); + const layout = getReflexiveAssociationLayout(node, { reflexiveSide: "lower-left" }); + expect(layout.side).toBe("lower-left"); + expect(layout.isLower).toBe(true); + expect(layout.startAnchor.y).toBeGreaterThan(node.internals.positionAbsolute.y + node.measured.height / 2); + expect(layout.outerEdgeY).toBeGreaterThan(layout.startAnchor.y); + }); + + it("upper loops extend upward (outerEdgeY < startAnchor.y)", () => { + const node = makeNode(0, 0, 200, 100); + const left = getReflexiveAssociationLayout(node, { reflexiveSide: "left" }); + const right = getReflexiveAssociationLayout(node, { reflexiveSide: "right" }); + expect(left.outerEdgeY).toBeLessThan(left.startAnchor.y); + expect(right.outerEdgeY).toBeLessThan(right.startAnchor.y); }); it("clamps loopWidth and loopHeight to their minimums for invalid data values", () => { diff --git a/src/components/flow/utils/reflexiveAssociationUtils.js b/src/components/flow/utils/reflexiveAssociationUtils.js index af05aec..ded3631 100644 --- a/src/components/flow/utils/reflexiveAssociationUtils.js +++ b/src/components/flow/utils/reflexiveAssociationUtils.js @@ -3,6 +3,8 @@ const REFLEXIVE_CORNER_RADIUS = 5 const REFLEXIVE_MIN_SEGMENT = 12 const REFLEXIVE_SIDE_LEFT = 'left' const REFLEXIVE_SIDE_RIGHT = 'right' +const REFLEXIVE_SIDE_LOWER_RIGHT = 'lower-right' +const REFLEXIVE_SIDE_LOWER_LEFT = 'lower-left' function getNodePosition(node) { return ( @@ -88,11 +90,20 @@ export function getReflexiveSide(data) { if (data?.reflexiveSide === REFLEXIVE_SIDE_LEFT) { return REFLEXIVE_SIDE_LEFT } + if (data?.reflexiveSide === REFLEXIVE_SIDE_LOWER_RIGHT) { + return REFLEXIVE_SIDE_LOWER_RIGHT + } + if (data?.reflexiveSide === REFLEXIVE_SIDE_LOWER_LEFT) { + return REFLEXIVE_SIDE_LOWER_LEFT + } const reflexiveIndex = Number.isFinite(data?.reflexiveIndex) ? Number(data.reflexiveIndex) : 0 - return reflexiveIndex === 1 ? REFLEXIVE_SIDE_RIGHT : REFLEXIVE_SIDE_LEFT + if (reflexiveIndex === 1) return REFLEXIVE_SIDE_RIGHT + if (reflexiveIndex === 2) return REFLEXIVE_SIDE_LOWER_RIGHT + if (reflexiveIndex === 3) return REFLEXIVE_SIDE_LOWER_LEFT + return REFLEXIVE_SIDE_LEFT } export function getReflexiveNodeRect(node) { @@ -121,18 +132,19 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { } const side = getReflexiveSide(data) + const isRight = side === REFLEXIVE_SIDE_RIGHT || side === REFLEXIVE_SIDE_LOWER_RIGHT + const isLower = side === REFLEXIVE_SIDE_LOWER_RIGHT || side === REFLEXIVE_SIDE_LOWER_LEFT + const horizontalInset = Math.min(REFLEXIVE_INSET_LIMIT, rect.width / 4) const verticalInset = Math.min(REFLEXIVE_INSET_LIMIT, rect.height / 4) + const startAnchor = { - x: side === REFLEXIVE_SIDE_RIGHT ? rect.x + rect.width : rect.x, - y: rect.y + verticalInset, + x: isRight ? rect.x + rect.width : rect.x, + y: isLower ? rect.y + rect.height - verticalInset : rect.y + verticalInset, } const endAnchor = { - x: - side === REFLEXIVE_SIDE_RIGHT - ? startAnchor.x - horizontalInset - : startAnchor.x + horizontalInset, - y: rect.y, + x: isRight ? startAnchor.x - horizontalInset : startAnchor.x + horizontalInset, + y: isLower ? rect.y + rect.height : rect.y, } const minLoopWidth = REFLEXIVE_MIN_SEGMENT @@ -151,29 +163,28 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { normalizePositive(data?.loopHeight) ?? defaultLoopHeight, ) - const outerX = - side === REFLEXIVE_SIDE_RIGHT - ? startAnchor.x + loopWidth - : startAnchor.x - loopWidth - const topY = startAnchor.y - loopHeight + const outerX = isRight ? startAnchor.x + loopWidth : startAnchor.x - loopWidth + const outerEdgeY = isLower ? startAnchor.y + loopHeight : startAnchor.y - loopHeight + const pathPoints = [ startAnchor, { x: outerX, y: startAnchor.y }, - { x: outerX, y: topY }, - { x: endAnchor.x, y: topY }, + { x: outerX, y: outerEdgeY }, + { x: endAnchor.x, y: outerEdgeY }, endAnchor, ] const topSegmentCenter = { x: (outerX + endAnchor.x) / 2, - y: topY, + y: outerEdgeY, } const outerSegmentCenter = { x: outerX, - y: (startAnchor.y + topY) / 2, + y: (startAnchor.y + outerEdgeY) / 2, } return { side, + isLower, rect, edgePath: getRoundedPolylinePath(pathPoints, REFLEXIVE_CORNER_RADIUS), pathPoints, @@ -184,7 +195,7 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { minLoopWidth, minLoopHeight, outerX, - topY, + outerEdgeY, topSegmentCenter, outerSegmentCenter, helperAnchor: topSegmentCenter, @@ -204,4 +215,3 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { ], } } - diff --git a/src/hooks/useModelState.js b/src/hooks/useModelState.js index 8cee42b..12b720f 100644 --- a/src/hooks/useModelState.js +++ b/src/hooks/useModelState.js @@ -829,7 +829,7 @@ export function useModelState({ } return count + 1 }, 0) - return existingCount >= 2 + return existingCount >= 4 } return current.some((edge) => { @@ -1911,7 +1911,9 @@ export function useModelState({ } } - const rawHeight = layout.startAnchor.y - nextPoint.y + const rawHeight = layout.isLower + ? nextPoint.y - layout.startAnchor.y + : layout.startAnchor.y - nextPoint.y const nextLoopHeight = roundLoopMetric( Math.max(layout.minLoopHeight, rawHeight), ) diff --git a/src/model/__tests__/edgeUtils.test.js b/src/model/__tests__/edgeUtils.test.js index b27bcdd..ae5e268 100644 --- a/src/model/__tests__/edgeUtils.test.js +++ b/src/model/__tests__/edgeUtils.test.js @@ -70,6 +70,14 @@ describe("normalizeReflexiveSide", () => { expect(normalizeReflexiveSide("right")).toBe("right"); }); + it("returns 'lower-right' for 'lower-right'", () => { + expect(normalizeReflexiveSide("lower-right")).toBe("lower-right"); + }); + + it("returns 'lower-left' for 'lower-left'", () => { + expect(normalizeReflexiveSide("lower-left")).toBe("lower-left"); + }); + it("returns null for any other value", () => { expect(normalizeReflexiveSide("top")).toBeNull(); expect(normalizeReflexiveSide("")).toBeNull(); @@ -105,7 +113,7 @@ describe("normalizeEdges", () => { const result = normalizeEdges([edge]); expect(result[0].data.reflexiveIndex).toBe(0); expect(result[0].data.reflexiveCount).toBe(1); - expect(["left", "right"]).toContain(result[0].data.reflexiveSide); + expect(["left", "right", "lower-right", "lower-left"]).toContain(result[0].data.reflexiveSide); }); it("assigns parallelIndex and parallelCount to parallel association edges", () => { diff --git a/src/model/dialogCopy.js b/src/model/dialogCopy.js index e4a94c4..e86b14c 100644 --- a/src/model/dialogCopy.js +++ b/src/model/dialogCopy.js @@ -55,7 +55,7 @@ export const DIALOG_COPY = { reflexive: { title: 'Reflexive association limit', description: - 'Up to two reflexive associations on the same class are supported. Remove an existing reflexive association before creating another.', + 'Up to four reflexive associations on the same class are supported. Remove an existing reflexive association before creating another.', }, association: { title: 'Duplicate association', diff --git a/src/model/edgeUtils.js b/src/model/edgeUtils.js index 3b561e4..240bd12 100644 --- a/src/model/edgeUtils.js +++ b/src/model/edgeUtils.js @@ -10,6 +10,15 @@ import { const REFLEXIVE_SIDE_LEFT = 'left' const REFLEXIVE_SIDE_RIGHT = 'right' +const REFLEXIVE_SIDE_LOWER_RIGHT = 'lower-right' +const REFLEXIVE_SIDE_LOWER_LEFT = 'lower-left' + +const ALL_REFLEXIVE_SIDES = [ + REFLEXIVE_SIDE_LEFT, + REFLEXIVE_SIDE_RIGHT, + REFLEXIVE_SIDE_LOWER_RIGHT, + REFLEXIVE_SIDE_LOWER_LEFT, +] export function normalizeControlPoints(value) { if (!Array.isArray(value)) { @@ -36,6 +45,12 @@ export function normalizeReflexiveSide(value) { if (value === REFLEXIVE_SIDE_LEFT) { return REFLEXIVE_SIDE_LEFT } + if (value === REFLEXIVE_SIDE_LOWER_RIGHT) { + return REFLEXIVE_SIDE_LOWER_RIGHT + } + if (value === REFLEXIVE_SIDE_LOWER_LEFT) { + return REFLEXIVE_SIDE_LOWER_LEFT + } return null } @@ -49,9 +64,7 @@ function getLegacyReflexiveSide(edge, fallbackIndex = 0) { const rawIndex = Number.isFinite(edge?.data?.reflexiveIndex) ? Number(edge.data.reflexiveIndex) : Number(fallbackIndex) - return Math.abs(rawIndex % 2) === 1 - ? REFLEXIVE_SIDE_RIGHT - : REFLEXIVE_SIDE_LEFT + return ALL_REFLEXIVE_SIDES[Math.abs(Math.trunc(rawIndex)) % 4] } function assignReflexiveSides(orderedEdges) { @@ -88,9 +101,8 @@ function assignReflexiveSides(orderedEdges) { } const preferredSide = getLegacyReflexiveSide(edge, index) - const oppositeSide = getOppositeReflexiveSide(preferredSide) - const resolvedSide = usedSides.has(preferredSide) && !usedSides.has(oppositeSide) - ? oppositeSide + const resolvedSide = usedSides.has(preferredSide) + ? (ALL_REFLEXIVE_SIDES.find((s) => !usedSides.has(s)) ?? preferredSide) : preferredSide sideById.set(edge.id, resolvedSide) diff --git a/src/model/javaModelizerImport.js b/src/model/javaModelizerImport.js index d080fd7..87bccdc 100644 --- a/src/model/javaModelizerImport.js +++ b/src/model/javaModelizerImport.js @@ -352,7 +352,7 @@ export function importJavaModelizer(text, fileName) { const isReflexive = sourceId === targetId if (isReflexive) { const existingCount = reflexiveCountByClass.get(sourceId) ?? 0 - if (existingCount >= 2) { + if (existingCount >= 4) { return } reflexiveCountByClass.set(sourceId, existingCount + 1) From 1b5f5ed969804fff924e658f8b7c43bb830cf36a Mon Sep 17 00:00:00 2001 From: Laurent Haan Date: Tue, 12 May 2026 14:49:56 +0200 Subject: [PATCH 2/3] Fix width handle direction for lower-right reflexive association The side check only matched 'right', so 'lower-right' fell into the left-side branch and computed the width in the wrong direction. Both right-side positions extend outerX to the right of the node and need nextPoint.x - startAnchor.x. --- src/hooks/useModelState.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useModelState.js b/src/hooks/useModelState.js index 12b720f..7c08a48 100644 --- a/src/hooks/useModelState.js +++ b/src/hooks/useModelState.js @@ -1892,7 +1892,7 @@ export function useModelState({ if (handleKey === 'width') { const rawWidth = - layout.side === 'right' + layout.side === 'right' || layout.side === 'lower-right' ? nextPoint.x - layout.startAnchor.x : layout.startAnchor.x - nextPoint.x const nextLoopWidth = roundLoopMetric( From b57d291d7ce8f00cdadfba073dae30fb1cd8a02a Mon Sep 17 00:00:00 2001 From: Laurent Haan Date: Tue, 12 May 2026 15:11:07 +0200 Subject: [PATCH 3/3] Apply code review fixes - Expose isRight from getReflexiveAssociationLayout so callers do not re-derive it from side string comparisons; use layout.isRight in both ReflexiveAssociation.jsx and useModelState.js - Rename topSegmentCenter to outerEdgeSegmentCenter to match the outerEdgeY naming introduced for lower loops - Extract getEndRoleTransform helper in ReflexiveAssociation.jsx for consistency with the other three label transform functions - Remove getOppositeReflexiveSide, which had no remaining callers after the third-pass logic was replaced with the four-side cycle - Add tests: outerX direction for all four sides, four reflexive edges each assigned a distinct side, import limit accepts 4 and drops a 5th - Fix javaModelizerImport test to filter by REFLEXIVE_EDGE_TYPE instead of the incorrect "reflexive" string (test was passing vacuously) --- .../flow/edges/ReflexiveAssociation.jsx | 16 +++++++----- .../reflexiveAssociationUtils.test.js | 18 +++++++++++++ .../flow/utils/reflexiveAssociationUtils.js | 11 ++++---- src/hooks/useModelState.js | 7 +++-- src/model/__tests__/edgeUtils.test.js | 26 ++++++++++--------- .../__tests__/javaModelizerImport.test.js | 20 +++++++++++--- src/model/edgeUtils.js | 6 ----- 7 files changed, 67 insertions(+), 37 deletions(-) diff --git a/src/components/flow/edges/ReflexiveAssociation.jsx b/src/components/flow/edges/ReflexiveAssociation.jsx index 062d7eb..be7a755 100644 --- a/src/components/flow/edges/ReflexiveAssociation.jsx +++ b/src/components/flow/edges/ReflexiveAssociation.jsx @@ -26,6 +26,12 @@ function getStartRoleTransform(isRight, x, y, isLower) { return `translate(${xTranslate}, ${yTranslate}) translate(${x}px, ${yOffset}px)` } +function getEndRoleTransform(x, y, isLower) { + const yTranslate = isLower ? '0%' : '-100%' + const yOffset = isLower ? y + 2 : y - 2 + return `translate(-50%, ${yTranslate}) translate(${x}px, ${yOffset}px)` +} + export function ReflexiveAssociation({ id, source, @@ -40,15 +46,13 @@ export function ReflexiveAssociation({ return null } - const side = layout.side - const isRight = side === 'right' || side === 'lower-right' - const isLower = layout.isLower + const { isRight, isLower } = layout const startX = layout.startAnchor.x const startY = layout.startAnchor.y const endX = layout.endAnchor.x const endY = layout.endAnchor.y - const roleBX = layout.topSegmentCenter.x - const roleBY = layout.topSegmentCenter.y + const roleBX = layout.outerEdgeSegmentCenter.x + const roleBY = layout.outerEdgeSegmentCenter.y const associationNameX = isRight ? layout.outerSegmentCenter.x + 4 : layout.outerSegmentCenter.x - 4 @@ -108,7 +112,7 @@ export function ReflexiveAssociation({ ) : null} {roleB ? ( ) : null} diff --git a/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js b/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js index f318b95..5540bb3 100644 --- a/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js +++ b/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js @@ -145,7 +145,9 @@ describe("getReflexiveAssociationLayout", () => { expect(layout).toHaveProperty("loopWidth"); expect(layout).toHaveProperty("loopHeight"); expect(layout).toHaveProperty("side"); + expect(layout).toHaveProperty("isRight"); expect(layout).toHaveProperty("isLower"); + expect(layout).toHaveProperty("outerEdgeSegmentCenter"); expect(layout).toHaveProperty("resizeHandles"); }); @@ -181,6 +183,22 @@ describe("getReflexiveAssociationLayout", () => { expect(layout.outerEdgeY).toBeGreaterThan(layout.startAnchor.y); }); + it("right-side layouts extend outerX to the right of startAnchor", () => { + const node = makeNode(0, 0, 200, 100); + const right = getReflexiveAssociationLayout(node, { reflexiveSide: "right" }); + const lowerRight = getReflexiveAssociationLayout(node, { reflexiveSide: "lower-right" }); + expect(right.outerX).toBeGreaterThan(right.startAnchor.x); + expect(lowerRight.outerX).toBeGreaterThan(lowerRight.startAnchor.x); + }); + + it("left-side layouts extend outerX to the left of startAnchor", () => { + const node = makeNode(0, 0, 200, 100); + const left = getReflexiveAssociationLayout(node, { reflexiveSide: "left" }); + const lowerLeft = getReflexiveAssociationLayout(node, { reflexiveSide: "lower-left" }); + expect(left.outerX).toBeLessThan(left.startAnchor.x); + expect(lowerLeft.outerX).toBeLessThan(lowerLeft.startAnchor.x); + }); + it("upper loops extend upward (outerEdgeY < startAnchor.y)", () => { const node = makeNode(0, 0, 200, 100); const left = getReflexiveAssociationLayout(node, { reflexiveSide: "left" }); diff --git a/src/components/flow/utils/reflexiveAssociationUtils.js b/src/components/flow/utils/reflexiveAssociationUtils.js index ded3631..190c601 100644 --- a/src/components/flow/utils/reflexiveAssociationUtils.js +++ b/src/components/flow/utils/reflexiveAssociationUtils.js @@ -173,7 +173,7 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { { x: endAnchor.x, y: outerEdgeY }, endAnchor, ] - const topSegmentCenter = { + const outerEdgeSegmentCenter = { x: (outerX + endAnchor.x) / 2, y: outerEdgeY, } @@ -184,6 +184,7 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { return { side, + isRight, isLower, rect, edgePath: getRoundedPolylinePath(pathPoints, REFLEXIVE_CORNER_RADIUS), @@ -196,9 +197,9 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { minLoopHeight, outerX, outerEdgeY, - topSegmentCenter, + outerEdgeSegmentCenter, outerSegmentCenter, - helperAnchor: topSegmentCenter, + helperAnchor: outerEdgeSegmentCenter, resizeHandles: [ { key: 'width', @@ -209,8 +210,8 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { { key: 'height', axis: 'y', - x: topSegmentCenter.x, - y: topSegmentCenter.y, + x: outerEdgeSegmentCenter.x, + y: outerEdgeSegmentCenter.y, }, ], } diff --git a/src/hooks/useModelState.js b/src/hooks/useModelState.js index 7c08a48..1110883 100644 --- a/src/hooks/useModelState.js +++ b/src/hooks/useModelState.js @@ -1891,10 +1891,9 @@ export function useModelState({ } if (handleKey === 'width') { - const rawWidth = - layout.side === 'right' || layout.side === 'lower-right' - ? nextPoint.x - layout.startAnchor.x - : layout.startAnchor.x - nextPoint.x + const rawWidth = layout.isRight + ? nextPoint.x - layout.startAnchor.x + : layout.startAnchor.x - nextPoint.x const nextLoopWidth = roundLoopMetric( Math.max(layout.minLoopWidth, rawWidth), ) diff --git a/src/model/__tests__/edgeUtils.test.js b/src/model/__tests__/edgeUtils.test.js index ae5e268..236c65f 100644 --- a/src/model/__tests__/edgeUtils.test.js +++ b/src/model/__tests__/edgeUtils.test.js @@ -3,7 +3,6 @@ import { normalizeControlPoints, normalizePositiveNumber, normalizeReflexiveSide, - getOppositeReflexiveSide, normalizeEdges, } from "../edgeUtils.js"; import { @@ -86,17 +85,6 @@ describe("normalizeReflexiveSide", () => { }); }); -describe("getOppositeReflexiveSide", () => { - it("returns 'left' for 'right'", () => { - expect(getOppositeReflexiveSide("right")).toBe("left"); - }); - - it("returns 'right' for 'left' (and any other value)", () => { - expect(getOppositeReflexiveSide("left")).toBe("right"); - expect(getOppositeReflexiveSide(null)).toBe("right"); - }); -}); - describe("normalizeEdges", () => { it("returns an empty array for empty input", () => { expect(normalizeEdges([])).toEqual([]); @@ -116,6 +104,20 @@ describe("normalizeEdges", () => { expect(["left", "right", "lower-right", "lower-left"]).toContain(result[0].data.reflexiveSide); }); + it("assigns all four distinct sides when four reflexive edges share a node", () => { + const edges = ["e1", "e2", "e3", "e4"].map((id) => ({ + id, + type: REFLEXIVE_EDGE_TYPE, + source: "node1", + target: "node1", + data: {}, + })); + const result = normalizeEdges(edges); + const sides = result.map((e) => e.data.reflexiveSide); + expect(new Set(sides).size).toBe(4); + expect(new Set(sides)).toEqual(new Set(["left", "right", "lower-right", "lower-left"])); + }); + it("assigns parallelIndex and parallelCount to parallel association edges", () => { const e1 = { id: "e1", type: ASSOCIATION_EDGE_TYPE, source: "a", target: "b", data: {} }; const e2 = { id: "e2", type: ASSOCIATION_EDGE_TYPE, source: "a", target: "b", data: {} }; diff --git a/src/model/__tests__/javaModelizerImport.test.js b/src/model/__tests__/javaModelizerImport.test.js index 3cc7a91..cd6b96f 100644 --- a/src/model/__tests__/javaModelizerImport.test.js +++ b/src/model/__tests__/javaModelizerImport.test.js @@ -6,6 +6,7 @@ import { importJavaModelizer, } from "../javaModelizerImport.js"; import { ATTRIBUTE_TYPE_PARAMS_DEFAULT } from "../../attributes.js"; +import { REFLEXIVE_EDGE_TYPE } from "../constants.js"; // ─── parseDob ──────────────────────────────────────────────────────────────── @@ -210,15 +211,26 @@ describe("importJavaModelizer", () => { expect(result.edges[0].data.multiplicityB).toBe("n"); }); - it("enforces a maximum of 2 reflexive edges per class", () => { - const links = [1, 2, 3].map((i) => ({ + it("imports up to 4 reflexive edges per class", () => { + const links = [1, 2, 3, 4].map((i) => ({ name: `rel${i}`, endpoints: [{ dob: "A" }, { dob: "A" }], })); const mod = { tables: [{ name: "A", links }] }; const result = importJavaModelizer(JSON.stringify(mod)); - const reflexiveEdges = result.edges.filter((e) => e.type === "reflexive"); - expect(reflexiveEdges.length).toBeLessThanOrEqual(2); + const reflexiveEdges = result.edges.filter((e) => e.type === REFLEXIVE_EDGE_TYPE); + expect(reflexiveEdges.length).toBe(4); + }); + + it("drops a 5th reflexive edge on the same class", () => { + const links = [1, 2, 3, 4, 5].map((i) => ({ + name: `rel${i}`, + endpoints: [{ dob: "A" }, { dob: "A" }], + })); + const mod = { tables: [{ name: "A", links }] }; + const result = importJavaModelizer(JSON.stringify(mod)); + const reflexiveEdges = result.edges.filter((e) => e.type === REFLEXIVE_EDGE_TYPE); + expect(reflexiveEdges.length).toBe(4); }); it("uses deriveModelName to set the model name from fileName", () => { diff --git a/src/model/edgeUtils.js b/src/model/edgeUtils.js index 240bd12..38a8bc7 100644 --- a/src/model/edgeUtils.js +++ b/src/model/edgeUtils.js @@ -54,12 +54,6 @@ export function normalizeReflexiveSide(value) { return null } -export function getOppositeReflexiveSide(side) { - return side === REFLEXIVE_SIDE_RIGHT - ? REFLEXIVE_SIDE_LEFT - : REFLEXIVE_SIDE_RIGHT -} - function getLegacyReflexiveSide(edge, fallbackIndex = 0) { const rawIndex = Number.isFinite(edge?.data?.reflexiveIndex) ? Number(edge.data.reflexiveIndex)