mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
test: cron schedule + webhook URL + canvas layout coverage (#73)
- calculateNextRun: field-count, garbage, bad-zone errors; CRON_TZ actually shifts the instant (UTC vs Asia/Tokyo) - validWebhookURL: http/https only, host required, whitespace trimmed - canvas layout math extracted to canvas/layout.ts (component files may only export components) and covered: rectsOverlap gap zone, resolveFreeSpot grid-snap/collision/bounds-fallback, groupSlot fit/append/grow/ignore-dragged, buildMetadataFromFlow serialization Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c4307034ec
commit
a6b8397dd8
5 files changed
+408
-159
No files matched your search
@@ -0,0 +1,66 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateNextRun(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
schedule string
|
||||||
|
timezone string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"every minute", "* * * * *", "", false},
|
||||||
|
{"daily at 4am", "0 4 * * *", "UTC", false},
|
||||||
|
{"weekday run", "0 9 * * 1-5", "Europe/Prague", false},
|
||||||
|
{"whitespace tolerated", " */15 * * * * ", "", false},
|
||||||
|
{"empty schedule", "", "", true},
|
||||||
|
{"bad field count", "* * *", "", true},
|
||||||
|
{"garbage", "not a cron", "", true},
|
||||||
|
{"bad timezone", "0 4 * * *", "Mars/Olympus", true},
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
next, err := calculateNextRun(tc.schedule, tc.timezone)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got next=%v", next)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if next == nil {
|
||||||
|
t.Fatal("expected non-nil next run")
|
||||||
|
}
|
||||||
|
if !next.After(now) {
|
||||||
|
t.Errorf("next run %v is not after %v", next, now)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateNextRunTimezone(t *testing.T) {
|
||||||
|
// CRON_TZ must change the computed instant: 04:00 in a fixed-offset zone
|
||||||
|
// lands at a different UTC hour than 04:00 UTC.
|
||||||
|
utcNext, err := calculateNextRun("0 4 * * *", "UTC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("utc: %v", err)
|
||||||
|
}
|
||||||
|
tokyoNext, err := calculateNextRun("0 4 * * *", "Asia/Tokyo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tokyo: %v", err)
|
||||||
|
}
|
||||||
|
if utcNext.Equal(*tokyoNext) {
|
||||||
|
t.Fatalf("timezone had no effect: both %v", utcNext)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(utcNext.Location().String(), "Local") {
|
||||||
|
// UTC-scheduled runs still land in server-local time representation.
|
||||||
|
t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,3 +31,33 @@ func TestWebhookEventMatches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidWebhookURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"https", "https://hooks.example.com/wh/abc", false},
|
||||||
|
{"http", "http://internal.lan:8080/notify", false},
|
||||||
|
{"whitespace trimmed", " https://example.com/hook ", false},
|
||||||
|
{"with query", "https://example.com/hook?token=x", false},
|
||||||
|
{"ftp rejected", "ftp://files.example.com/x", true},
|
||||||
|
{"no scheme", "example.com/hook", true},
|
||||||
|
{"bare scheme", "https://", true},
|
||||||
|
{"empty", "", true},
|
||||||
|
{"javascript rejected", "javascript:alert(1)", true},
|
||||||
|
{"host empty", "http:///path-only", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validWebhookURL(tc.url)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Errorf("validWebhookURL(%q) = nil, want error", tc.url)
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Errorf("validWebhookURL(%q) = %v, want nil", tc.url, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,14 +25,22 @@ import {
|
|||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { useToast } from '@/shared/hooks/use-toast';
|
import { useToast } from '@/shared/hooks/use-toast';
|
||||||
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
|
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
|
||||||
import {
|
import { DEFAULT_VIEWPORT, type ProjectCanvasMetadata } from '../model';
|
||||||
DEFAULT_VIEWPORT,
|
|
||||||
type CanvasGroup,
|
|
||||||
type CanvasNodeLayout,
|
|
||||||
type ProjectCanvasMetadata,
|
|
||||||
} from '../model';
|
|
||||||
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||||
import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
|
import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
|
||||||
|
import {
|
||||||
|
buildMetadataFromFlow,
|
||||||
|
GROUP_DEFAULT_HEIGHT,
|
||||||
|
GROUP_DEFAULT_WIDTH,
|
||||||
|
GROUP_PAD_BOTTOM,
|
||||||
|
GROUP_PAD_TOP,
|
||||||
|
GROUP_PAD_X,
|
||||||
|
GRID_STEP,
|
||||||
|
groupSlot,
|
||||||
|
resolveFreeSpot,
|
||||||
|
type CanvasNode,
|
||||||
|
type Rect,
|
||||||
|
} from './layout';
|
||||||
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
||||||
import { ServiceInspector, type InspectorGroup } from './inspector';
|
import { ServiceInspector, type InspectorGroup } from './inspector';
|
||||||
import { serviceAccent } from './service-visuals';
|
import { serviceAccent } from './service-visuals';
|
||||||
@@ -65,11 +73,7 @@ type CanvasProps = {
|
|||||||
|
|
||||||
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
|
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
|
||||||
type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
|
type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
|
||||||
type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
|
|
||||||
type CanvasEdge = Edge<{ reasons: string[] }>;
|
type CanvasEdge = Edge<{ reasons: string[] }>;
|
||||||
|
|
||||||
const GROUP_DEFAULT_WIDTH = 340;
|
|
||||||
const GROUP_DEFAULT_HEIGHT = 230;
|
|
||||||
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
|
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
|
||||||
|
|
||||||
const nodeTypes: NodeTypes = {
|
const nodeTypes: NodeTypes = {
|
||||||
@@ -176,155 +180,6 @@ function toFlowEdges(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_PAD_X = 28;
|
|
||||||
const GROUP_PAD_TOP = 56;
|
|
||||||
const GROUP_PAD_BOTTOM = 24;
|
|
||||||
const GROUP_ROW_GAP = 16;
|
|
||||||
const GRID_STEP = 26;
|
|
||||||
const NODE_GAP = 14;
|
|
||||||
|
|
||||||
type Rect = { x: number; y: number; w: number; h: number };
|
|
||||||
|
|
||||||
function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
||||||
return (
|
|
||||||
a.x < b.x + b.w + NODE_GAP &&
|
|
||||||
a.x + a.w + NODE_GAP > b.x &&
|
|
||||||
a.y < b.y + b.h + NODE_GAP &&
|
|
||||||
a.y + a.h + NODE_GAP > b.y
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nearest grid-aligned position where a node fits without touching any
|
|
||||||
// occupied rect. Spiral-searches outward on the canvas lattice; falls back to
|
|
||||||
// the given position when the neighbourhood is fully boxed in.
|
|
||||||
function resolveFreeSpot(
|
|
||||||
desired: { x: number; y: number },
|
|
||||||
occupied: Rect[],
|
|
||||||
fallback: { x: number; y: number },
|
|
||||||
bounds?: Rect,
|
|
||||||
): { x: number; y: number } {
|
|
||||||
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
|
|
||||||
const base = { x: snap(desired.x), y: snap(desired.y) };
|
|
||||||
const fits = (x: number, y: number) => {
|
|
||||||
if (bounds && (x < bounds.x || y < bounds.y || x + NODE_WIDTH > bounds.x + bounds.w || y + NODE_HEIGHT > bounds.y + bounds.h)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !occupied.some((rect) => rectsOverlap({ x, y, w: NODE_WIDTH, h: NODE_HEIGHT }, rect));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fits(base.x, base.y)) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let ring = 1; ring <= 12; ring++) {
|
|
||||||
for (let dx = -ring; dx <= ring; dx++) {
|
|
||||||
for (let dy = -ring; dy <= ring; dy++) {
|
|
||||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const x = base.x + dx * GRID_STEP;
|
|
||||||
const y = base.y + dy * GRID_STEP;
|
|
||||||
if (fits(x, y)) {
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where an incoming service lands inside a group frame. If the point already
|
|
||||||
// fits inside the padded area, honour it. Otherwise append beneath the lowest
|
|
||||||
// member and grow the frame just enough — keeps groups compact instead of
|
|
||||||
// ballooning the frame to wherever the node used to sit.
|
|
||||||
function groupSlot(
|
|
||||||
nodes: CanvasNode[],
|
|
||||||
groupId: string,
|
|
||||||
group: CanvasNode,
|
|
||||||
desiredX: number,
|
|
||||||
desiredY: number,
|
|
||||||
ignoreServiceId?: string,
|
|
||||||
): { x: number; y: number; width: number; height: number } {
|
|
||||||
const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH;
|
|
||||||
const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT;
|
|
||||||
|
|
||||||
const members = nodes.filter(
|
|
||||||
(node) => node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId,
|
|
||||||
);
|
|
||||||
const memberRects: Rect[] = members.map((node) => ({
|
|
||||||
x: node.position.x,
|
|
||||||
y: node.position.y,
|
|
||||||
w: NODE_WIDTH,
|
|
||||||
h: NODE_HEIGHT,
|
|
||||||
}));
|
|
||||||
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
|
|
||||||
const snappedX = snap(desiredX);
|
|
||||||
const snappedY = snap(desiredY);
|
|
||||||
const snappedFits =
|
|
||||||
snappedX >= GROUP_PAD_X &&
|
|
||||||
snappedY >= GROUP_PAD_TOP &&
|
|
||||||
snappedX + NODE_WIDTH <= width - GROUP_PAD_X &&
|
|
||||||
snappedY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM;
|
|
||||||
const collides = memberRects.some((rect) =>
|
|
||||||
rectsOverlap({ x: snappedX, y: snappedY, w: NODE_WIDTH, h: NODE_HEIGHT }, rect),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snappedFits && !collides) {
|
|
||||||
return { x: snappedX, y: snappedY, width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP;
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId) {
|
|
||||||
bottom = Math.max(bottom, node.position.y + NODE_HEIGHT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = GROUP_PAD_X;
|
|
||||||
const y = bottom + GROUP_ROW_GAP;
|
|
||||||
return {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width: Math.max(width, x + NODE_WIDTH + GROUP_PAD_X),
|
|
||||||
height: Math.max(height, y + NODE_HEIGHT + GROUP_PAD_BOTTOM),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
|
|
||||||
const groups: CanvasGroup[] = [];
|
|
||||||
const layouts: CanvasNodeLayout[] = [];
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.type === 'groupNode') {
|
|
||||||
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
|
|
||||||
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
|
|
||||||
groups.push({
|
|
||||||
id: node.id,
|
|
||||||
title: node.data.title,
|
|
||||||
position: node.position,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'serviceNode') {
|
|
||||||
layouts.push({
|
|
||||||
serviceId: node.id,
|
|
||||||
position: node.position,
|
|
||||||
groupId: node.parentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
groups,
|
|
||||||
nodes: layouts,
|
|
||||||
edges: [],
|
|
||||||
viewport,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ZoomControls({ onTidy }: { onTidy: () => void }) {
|
function ZoomControls({ onTidy }: { onTidy: () => void }) {
|
||||||
const { zoom } = useViewport();
|
const { zoom } = useViewport();
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildMetadataFromFlow,
|
||||||
|
groupSlot,
|
||||||
|
rectsOverlap,
|
||||||
|
resolveFreeSpot,
|
||||||
|
} from './layout';
|
||||||
|
import { NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
|
||||||
|
import type { GroupNodeType, ServiceNodeType } from './nodes';
|
||||||
|
|
||||||
|
const GRID_STEP = 26;
|
||||||
|
const NODE_GAP = 14;
|
||||||
|
|
||||||
|
function serviceNode(
|
||||||
|
id: string,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
parentId?: string,
|
||||||
|
): ServiceNodeType {
|
||||||
|
return { id, type: 'serviceNode', position, parentId } as ServiceNodeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupNode(
|
||||||
|
id: string,
|
||||||
|
width = 340,
|
||||||
|
height = 230,
|
||||||
|
): GroupNodeType {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'groupNode',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
style: { width, height },
|
||||||
|
data: { title: `Group ${id}` },
|
||||||
|
} as GroupNodeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rectsOverlap', () => {
|
||||||
|
const a = { x: 0, y: 0, w: 100, h: 100 };
|
||||||
|
|
||||||
|
it('detects intersection', () => {
|
||||||
|
expect(rectsOverlap(a, { x: 50, y: 50, w: 100, h: 100 })).toBe(true);
|
||||||
|
expect(rectsOverlap(a, { x: -50, y: -50, w: 100, h: 100 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for fully disjoint rects', () => {
|
||||||
|
expect(rectsOverlap(a, { x: 500, y: 500, w: 100, h: 100 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the node gap as a collision zone', () => {
|
||||||
|
// b sits just inside NODE_GAP to the right of a.
|
||||||
|
expect(rectsOverlap(a, { x: 100 + NODE_GAP - 1, y: 0, w: 50, h: 50 })).toBe(true);
|
||||||
|
// Beyond the gap there is no overlap.
|
||||||
|
expect(rectsOverlap(a, { x: 100 + NODE_GAP + 1, y: 0, w: 50, h: 50 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveFreeSpot', () => {
|
||||||
|
it('snaps the desired position to the grid when free', () => {
|
||||||
|
const spot = resolveFreeSpot({ x: 40, y: 70 }, [], { x: 0, y: 0 });
|
||||||
|
expect(spot).toEqual({ x: 52, y: 78 });
|
||||||
|
expect(spot.x % GRID_STEP).toBe(0);
|
||||||
|
expect(spot.y % GRID_STEP).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a neighbouring free grid point when occupied', () => {
|
||||||
|
const occupied = [{ x: 0, y: 0, w: NODE_WIDTH, h: NODE_HEIGHT }];
|
||||||
|
const spot = resolveFreeSpot({ x: 0, y: 0 }, occupied, { x: -1, y: -1 });
|
||||||
|
expect(spot).not.toEqual({ x: -1, y: -1 });
|
||||||
|
expect(Number.isInteger(spot.x / GRID_STEP)).toBe(true);
|
||||||
|
expect(Number.isInteger(spot.y / GRID_STEP)).toBe(true);
|
||||||
|
expect(
|
||||||
|
rectsOverlap({ x: spot.x, y: spot.y, w: NODE_WIDTH, h: NODE_HEIGHT }, occupied[0]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when bounds reject every candidate', () => {
|
||||||
|
// Node is wider than the bounds, so nothing can fit.
|
||||||
|
const bounds = { x: 0, y: 0, w: 10, h: 10 };
|
||||||
|
const fallback = { x: 999, y: 888 };
|
||||||
|
expect(resolveFreeSpot({ x: 0, y: 0 }, [], fallback, bounds)).toEqual(fallback);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupSlot', () => {
|
||||||
|
it('honours a point that fits inside the padded frame', () => {
|
||||||
|
const group = groupNode('g1');
|
||||||
|
const slot = groupSlot([group], 'g1', group, 40, 70);
|
||||||
|
expect(slot).toEqual({ x: 52, y: 78, width: 340, height: 230 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends below the lowest member and grows the frame on collision', () => {
|
||||||
|
const group = groupNode('g1');
|
||||||
|
const member = serviceNode('s1', { x: 52, y: 78 }, 'g1');
|
||||||
|
const slot = groupSlot([group, member], 'g1', group, 40, 70);
|
||||||
|
// bottom = 78 + 104 = 182; y = 182 + 16 = 198; grows height to 326.
|
||||||
|
expect(slot.x).toBe(28);
|
||||||
|
expect(slot.y).toBe(198);
|
||||||
|
expect(slot.height).toBe(326);
|
||||||
|
expect(slot.width).toBe(340);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends under the padding when the desired point is outside', () => {
|
||||||
|
const group = groupNode('g1');
|
||||||
|
const slot = groupSlot([group], 'g1', group, 5000, 5000);
|
||||||
|
expect(slot).toEqual({ x: 28, y: 56, width: 340, height: 230 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the dragged node when checking collisions', () => {
|
||||||
|
const group = groupNode('g1');
|
||||||
|
const dragged = serviceNode('s1', { x: 52, y: 78 }, 'g1');
|
||||||
|
const slot = groupSlot([group, dragged], 'g1', group, 40, 70, 's1');
|
||||||
|
expect(slot).toEqual({ x: 52, y: 78, width: 340, height: 230 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMetadataFromFlow', () => {
|
||||||
|
it('serializes groups and service layouts', () => {
|
||||||
|
const nodes = [
|
||||||
|
{ ...groupNode('g1', 400, 300), position: { x: 10, y: 20 } },
|
||||||
|
serviceNode('s1', { x: 5, y: 6 }, 'g1'),
|
||||||
|
serviceNode('s2', { x: 100, y: 200 }),
|
||||||
|
];
|
||||||
|
const viewport = { x: 1, y: 2, zoom: 0.5 };
|
||||||
|
const meta = buildMetadataFromFlow(nodes, viewport);
|
||||||
|
|
||||||
|
expect(meta.groups).toEqual([
|
||||||
|
{ id: 'g1', title: 'Group g1', position: { x: 10, y: 20 }, width: 400, height: 300 },
|
||||||
|
]);
|
||||||
|
expect(meta.nodes).toEqual([
|
||||||
|
{ serviceId: 's1', position: { x: 5, y: 6 }, groupId: 'g1' },
|
||||||
|
{ serviceId: 's2', position: { x: 100, y: 200 }, groupId: undefined },
|
||||||
|
]);
|
||||||
|
expect(meta.edges).toEqual([]);
|
||||||
|
expect(meta.viewport).toEqual(viewport);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default group dimensions when style is missing', () => {
|
||||||
|
const bare = { id: 'g2', type: 'groupNode', position: { x: 0, y: 0 }, data: { title: 'bare' } } as GroupNodeType;
|
||||||
|
const meta = buildMetadataFromFlow([bare], { x: 0, y: 0, zoom: 1 });
|
||||||
|
expect(meta.groups[0].width).toBe(340);
|
||||||
|
expect(meta.groups[0].height).toBe(230);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { CanvasGroup, CanvasNodeLayout, ProjectCanvasMetadata } from '../model';
|
||||||
|
import { NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
|
||||||
|
import type { GroupNodeType, ServiceNodeType } from './nodes';
|
||||||
|
|
||||||
|
export type CanvasNode = ServiceNodeType | GroupNodeType;
|
||||||
|
export type Rect = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
export const GROUP_DEFAULT_WIDTH = 340;
|
||||||
|
export const GROUP_DEFAULT_HEIGHT = 230;
|
||||||
|
export const GROUP_PAD_X = 28;
|
||||||
|
export const GROUP_PAD_TOP = 56;
|
||||||
|
export const GROUP_PAD_BOTTOM = 24;
|
||||||
|
export const GROUP_ROW_GAP = 16;
|
||||||
|
export const GRID_STEP = 26;
|
||||||
|
export const NODE_GAP = 14;
|
||||||
|
|
||||||
|
export function rectsOverlap(a: Rect, b: Rect): boolean {
|
||||||
|
return (
|
||||||
|
a.x < b.x + b.w + NODE_GAP &&
|
||||||
|
a.x + a.w + NODE_GAP > b.x &&
|
||||||
|
a.y < b.y + b.h + NODE_GAP &&
|
||||||
|
a.y + a.h + NODE_GAP > b.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearest grid-aligned position where a node fits without touching any
|
||||||
|
// occupied rect. Spiral-searches outward on the canvas lattice; falls back to
|
||||||
|
// the given position when the neighbourhood is fully boxed in.
|
||||||
|
export function resolveFreeSpot(
|
||||||
|
desired: { x: number; y: number },
|
||||||
|
occupied: Rect[],
|
||||||
|
fallback: { x: number; y: number },
|
||||||
|
bounds?: Rect,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
|
||||||
|
const base = { x: snap(desired.x), y: snap(desired.y) };
|
||||||
|
const fits = (x: number, y: number) => {
|
||||||
|
if (bounds && (x < bounds.x || y < bounds.y || x + NODE_WIDTH > bounds.x + bounds.w || y + NODE_HEIGHT > bounds.y + bounds.h)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !occupied.some((rect) => rectsOverlap({ x, y, w: NODE_WIDTH, h: NODE_HEIGHT }, rect));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fits(base.x, base.y)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let ring = 1; ring <= 12; ring++) {
|
||||||
|
for (let dx = -ring; dx <= ring; dx++) {
|
||||||
|
for (let dy = -ring; dy <= ring; dy++) {
|
||||||
|
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const x = base.x + dx * GRID_STEP;
|
||||||
|
const y = base.y + dy * GRID_STEP;
|
||||||
|
if (fits(x, y)) {
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where an incoming service lands inside a group frame. If the point already
|
||||||
|
// fits inside the padded area, honour it. Otherwise append beneath the lowest
|
||||||
|
// member and grow the frame just enough — keeps groups compact instead of
|
||||||
|
// ballooning the frame to wherever the node used to sit.
|
||||||
|
export function groupSlot(
|
||||||
|
nodes: CanvasNode[],
|
||||||
|
groupId: string,
|
||||||
|
group: CanvasNode,
|
||||||
|
desiredX: number,
|
||||||
|
desiredY: number,
|
||||||
|
ignoreServiceId?: string,
|
||||||
|
): { x: number; y: number; width: number; height: number } {
|
||||||
|
const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH;
|
||||||
|
const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT;
|
||||||
|
|
||||||
|
const members = nodes.filter(
|
||||||
|
(node) => node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId,
|
||||||
|
);
|
||||||
|
const memberRects: Rect[] = members.map((node) => ({
|
||||||
|
x: node.position.x,
|
||||||
|
y: node.position.y,
|
||||||
|
w: NODE_WIDTH,
|
||||||
|
h: NODE_HEIGHT,
|
||||||
|
}));
|
||||||
|
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
|
||||||
|
const snappedX = snap(desiredX);
|
||||||
|
const snappedY = snap(desiredY);
|
||||||
|
const snappedFits =
|
||||||
|
snappedX >= GROUP_PAD_X &&
|
||||||
|
snappedY >= GROUP_PAD_TOP &&
|
||||||
|
snappedX + NODE_WIDTH <= width - GROUP_PAD_X &&
|
||||||
|
snappedY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM;
|
||||||
|
const collides = memberRects.some((rect) =>
|
||||||
|
rectsOverlap({ x: snappedX, y: snappedY, w: NODE_WIDTH, h: NODE_HEIGHT }, rect),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (snappedFits && !collides) {
|
||||||
|
return { x: snappedX, y: snappedY, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId) {
|
||||||
|
bottom = Math.max(bottom, node.position.y + NODE_HEIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = GROUP_PAD_X;
|
||||||
|
const y = bottom + GROUP_ROW_GAP;
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.max(width, x + NODE_WIDTH + GROUP_PAD_X),
|
||||||
|
height: Math.max(height, y + NODE_HEIGHT + GROUP_PAD_BOTTOM),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
|
||||||
|
const groups: CanvasGroup[] = [];
|
||||||
|
const layouts: CanvasNodeLayout[] = [];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'groupNode') {
|
||||||
|
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
|
||||||
|
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
|
||||||
|
groups.push({
|
||||||
|
id: node.id,
|
||||||
|
title: node.data.title,
|
||||||
|
position: node.position,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'serviceNode') {
|
||||||
|
layouts.push({
|
||||||
|
serviceId: node.id,
|
||||||
|
position: node.position,
|
||||||
|
groupId: node.parentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
nodes: layouts,
|
||||||
|
edges: [],
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user