@1agh/maude 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/sync/agent.ts +23 -8
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/sync-agent.test.ts +28 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
armature,
|
|
4
|
+
assignSlots,
|
|
5
|
+
balanceMoment,
|
|
6
|
+
dominanceRatio,
|
|
7
|
+
snapToFocal,
|
|
8
|
+
} from '../composition.ts';
|
|
9
|
+
import { apcaLc, bestHarmony, harmonize, harmonyDistance, valueRange } from '../palette.ts';
|
|
10
|
+
|
|
11
|
+
const BOX = { x: 0, y: 0, width: 90, height: 60 };
|
|
12
|
+
|
|
13
|
+
describe('armatures', () => {
|
|
14
|
+
test('thirds power points at exact 1/3 + 2/3', () => {
|
|
15
|
+
const a = armature(BOX, 'thirds');
|
|
16
|
+
expect(a.focals).toEqual([
|
|
17
|
+
{ x: 30, y: 20 },
|
|
18
|
+
{ x: 60, y: 20 },
|
|
19
|
+
{ x: 30, y: 40 },
|
|
20
|
+
{ x: 60, y: 40 },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
test('rabatment landscape lines at x=H and x=W−H', () => {
|
|
24
|
+
const a = armature(BOX, 'rabatment'); // W=90,H=60 → x=60, x=30
|
|
25
|
+
const xs = a.focals.map((f) => f.x).sort((p, q) => p - q);
|
|
26
|
+
expect(xs[0]).toBe(30);
|
|
27
|
+
expect(xs[xs.length - 1]).toBe(60);
|
|
28
|
+
});
|
|
29
|
+
test('golden points differ from thirds and are inside the box', () => {
|
|
30
|
+
const a = armature(BOX, 'golden');
|
|
31
|
+
expect(a.focals).toHaveLength(4);
|
|
32
|
+
for (const f of a.focals) {
|
|
33
|
+
expect(f.x).toBeGreaterThan(0);
|
|
34
|
+
expect(f.x).toBeLessThan(90);
|
|
35
|
+
}
|
|
36
|
+
// golden vertical ≈ 90/1.618 = 55.6, not the thirds 60
|
|
37
|
+
expect(a.focals.some((f) => Math.abs(f.x - 55.6) < 0.5)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
test('dynamic-symmetry yields 4 eyes inside the box, symmetric about center', () => {
|
|
40
|
+
const a = armature(BOX, 'dynamic-symmetry');
|
|
41
|
+
expect(a.focals).toHaveLength(4);
|
|
42
|
+
const cx = a.focals.reduce((s, f) => s + f.x, 0) / 4;
|
|
43
|
+
const cy = a.focals.reduce((s, f) => s + f.y, 0) / 4;
|
|
44
|
+
expect(cx).toBeCloseTo(45, 4); // centroid of the 4 eyes = canvas center
|
|
45
|
+
expect(cy).toBeCloseTo(30, 4);
|
|
46
|
+
});
|
|
47
|
+
test('snapToFocal picks the nearest power point', () => {
|
|
48
|
+
const a = armature(BOX, 'thirds');
|
|
49
|
+
expect(snapToFocal({ x: 28, y: 22 }, a)).toEqual({ x: 30, y: 20 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('constraint placement (no random scatter)', () => {
|
|
54
|
+
test('assignSlots fills focals first, then a calm grid — deterministic', () => {
|
|
55
|
+
const a = armature(BOX, 'thirds');
|
|
56
|
+
const p1 = assignSlots(6, a);
|
|
57
|
+
const p2 = assignSlots(6, a);
|
|
58
|
+
expect(p1).toEqual(p2); // deterministic
|
|
59
|
+
expect(p1.slice(0, 4)).toEqual(a.focals); // first 4 land on the power points
|
|
60
|
+
expect(p1).toHaveLength(6);
|
|
61
|
+
for (const p of p1) {
|
|
62
|
+
expect(p.x).toBeGreaterThanOrEqual(0);
|
|
63
|
+
expect(p.x).toBeLessThanOrEqual(90);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('VME visual balance', () => {
|
|
69
|
+
test('a symmetric pair scores ~1 (balanced)', () => {
|
|
70
|
+
const els = [
|
|
71
|
+
{ bbox: { x: 10, y: 25, width: 10, height: 10 } },
|
|
72
|
+
{ bbox: { x: 70, y: 25, width: 10, height: 10 } },
|
|
73
|
+
];
|
|
74
|
+
const r = balanceMoment(els, BOX);
|
|
75
|
+
expect(r.score).toBeGreaterThan(0.95);
|
|
76
|
+
});
|
|
77
|
+
test('all weight on the left scores low (off-balance)', () => {
|
|
78
|
+
const els = [
|
|
79
|
+
{ bbox: { x: 2, y: 25, width: 12, height: 12 } },
|
|
80
|
+
{ bbox: { x: 6, y: 5, width: 12, height: 12 } },
|
|
81
|
+
];
|
|
82
|
+
const r = balanceMoment(els, BOX);
|
|
83
|
+
expect(r.score).toBeLessThan(0.6);
|
|
84
|
+
expect(r.moment.x).toBeLessThan(0); // mass biased left of center
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('dominance', () => {
|
|
89
|
+
test('one big element dominates; equal elements do not', () => {
|
|
90
|
+
expect(
|
|
91
|
+
dominanceRatio([
|
|
92
|
+
{ bbox: { x: 0, y: 0, width: 40, height: 40 } },
|
|
93
|
+
{ bbox: { x: 0, y: 0, width: 10, height: 10 } },
|
|
94
|
+
])
|
|
95
|
+
).toBeCloseTo(16, 5); // 1600 / 100
|
|
96
|
+
expect(
|
|
97
|
+
dominanceRatio([
|
|
98
|
+
{ bbox: { x: 0, y: 0, width: 20, height: 20 } },
|
|
99
|
+
{ bbox: { x: 0, y: 0, width: 20, height: 20 } },
|
|
100
|
+
])
|
|
101
|
+
).toBeCloseTo(1, 5); // competing foci
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('color harmony (Cohen-Or templates)', () => {
|
|
106
|
+
test('analogous hues score ~0 (harmonious); a clash scores high', () => {
|
|
107
|
+
const harmonious = bestHarmony([20, 35, 50]); // tight cluster → fits a V/i wedge
|
|
108
|
+
expect(harmonious.distance).toBeLessThan(5);
|
|
109
|
+
const clash = bestHarmony([0, 70, 150, 250]); // scattered → no single template covers
|
|
110
|
+
expect(clash.distance).toBeGreaterThan(harmonious.distance);
|
|
111
|
+
});
|
|
112
|
+
test('complementary pair fits the I template near 0', () => {
|
|
113
|
+
const fit = bestHarmony([30, 210]); // 180° apart → complementary
|
|
114
|
+
expect(fit.distance).toBeLessThan(5);
|
|
115
|
+
});
|
|
116
|
+
test('harmonize snaps an outlier hue into a wedge', () => {
|
|
117
|
+
// template I = two 18° wedges 180° apart; rotation 30 → wedges at ~30 and ~210
|
|
118
|
+
const snapped = harmonize([30, 200], 'I', 30);
|
|
119
|
+
expect(snapped[0]).toBeCloseTo(30, 1); // already inside
|
|
120
|
+
// 200 is outside the [201,219] wedge → clamps to the near edge (201)
|
|
121
|
+
expect(Math.abs(snapped[1] - 210)).toBeLessThanOrEqual(9 + 1e-6);
|
|
122
|
+
});
|
|
123
|
+
test('harmonyDistance is 0 when all hues sit inside a wedge', () => {
|
|
124
|
+
expect(harmonyDistance([0, 5, -5], 'i', 0)).toBeCloseTo(0, 6); // i = 18° wedge at 0
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('value / contrast metrics', () => {
|
|
129
|
+
test('valueRange flags a washed-out (muddy) set vs a deep one', () => {
|
|
130
|
+
const muddy = valueRange(['#b8a0c0', '#a0b8b0', '#c0b0a0']); // all mid-value pastels
|
|
131
|
+
const deep = valueRange(['#0a0a14', '#ffe7b0']); // near-black ↔ near-white
|
|
132
|
+
expect(muddy).toBeLessThan(0.2);
|
|
133
|
+
expect(deep).toBeGreaterThan(0.6);
|
|
134
|
+
});
|
|
135
|
+
test('apcaLc: black-on-white is high, same-on-same is ~0', () => {
|
|
136
|
+
expect(apcaLc('#000000', '#ffffff')).toBeGreaterThan(100);
|
|
137
|
+
expect(apcaLc('#777777', '#777777')).toBeLessThan(1);
|
|
138
|
+
// mid-gray text on white clears the body-min Lc 75 only when dark enough
|
|
139
|
+
expect(apcaLc('#595959', '#ffffff')).toBeGreaterThan(60);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
EQUAL_AREA_CIRCLE_SCALE,
|
|
4
|
+
blobPath,
|
|
5
|
+
centroid,
|
|
6
|
+
centroidCenter,
|
|
7
|
+
chamferCorners,
|
|
8
|
+
convexHull,
|
|
9
|
+
equalWeightCircleDiameter,
|
|
10
|
+
overshoot,
|
|
11
|
+
pchipEval,
|
|
12
|
+
pchipPath,
|
|
13
|
+
pchipSlopes,
|
|
14
|
+
polygonArea,
|
|
15
|
+
routeConnector,
|
|
16
|
+
simplifyCollinear,
|
|
17
|
+
} from '../geometry.ts';
|
|
18
|
+
import type { Point } from '../primitives.ts';
|
|
19
|
+
|
|
20
|
+
describe('PCHIP monotone interpolation', () => {
|
|
21
|
+
const xs = [0, 1, 2, 3, 4];
|
|
22
|
+
const ys = [0, 0, 1, 1, 2]; // monotone non-decreasing, with flats
|
|
23
|
+
|
|
24
|
+
test('slopes are non-negative for monotone-increasing data', () => {
|
|
25
|
+
const m = pchipSlopes(xs, ys);
|
|
26
|
+
expect(m.every((s) => s >= -1e-12)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('interpolant never overshoots the data envelope (the whole point vs Bézier)', () => {
|
|
30
|
+
const lo = Math.min(...ys);
|
|
31
|
+
const hi = Math.max(...ys);
|
|
32
|
+
let prev = Number.NEGATIVE_INFINITY;
|
|
33
|
+
for (let i = 0; i <= 200; i++) {
|
|
34
|
+
const x = (i / 200) * 4;
|
|
35
|
+
const v = pchipEval(xs, ys, x);
|
|
36
|
+
expect(v).toBeGreaterThanOrEqual(lo - 1e-9);
|
|
37
|
+
expect(v).toBeLessThanOrEqual(hi + 1e-9);
|
|
38
|
+
expect(v).toBeGreaterThanOrEqual(prev - 1e-9); // monotone non-decreasing
|
|
39
|
+
prev = v;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('passes through every knot', () => {
|
|
44
|
+
for (let i = 0; i < xs.length; i++) {
|
|
45
|
+
expect(pchipEval(xs, ys, xs[i])).toBeCloseTo(ys[i], 9);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('pchipPath produces an M…C… cubic string', () => {
|
|
50
|
+
const d = pchipPath([
|
|
51
|
+
{ x: 0, y: 0 },
|
|
52
|
+
{ x: 1, y: 2 },
|
|
53
|
+
{ x: 2, y: 1 },
|
|
54
|
+
]);
|
|
55
|
+
expect(d.startsWith('M')).toBe(true);
|
|
56
|
+
expect(d).toContain('C');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('clamps outside the domain', () => {
|
|
60
|
+
expect(pchipEval(xs, ys, -5)).toBe(0);
|
|
61
|
+
expect(pchipEval(xs, ys, 99)).toBe(2);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('polygon helpers', () => {
|
|
66
|
+
const square: Point[] = [
|
|
67
|
+
{ x: 0, y: 0 },
|
|
68
|
+
{ x: 2, y: 0 },
|
|
69
|
+
{ x: 2, y: 2 },
|
|
70
|
+
{ x: 0, y: 2 },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
test('polygonArea (abs) of a 2×2 square is 4', () => {
|
|
74
|
+
expect(Math.abs(polygonArea(square))).toBeCloseTo(4, 9);
|
|
75
|
+
});
|
|
76
|
+
test('centroid of a square is its center', () => {
|
|
77
|
+
const c = centroid(square);
|
|
78
|
+
expect(c.x).toBeCloseTo(1, 9);
|
|
79
|
+
expect(c.y).toBeCloseTo(1, 9);
|
|
80
|
+
});
|
|
81
|
+
test('convex hull drops interior points', () => {
|
|
82
|
+
const pts: Point[] = [...square, { x: 1, y: 1 }];
|
|
83
|
+
const hull = convexHull(pts);
|
|
84
|
+
expect(hull).toHaveLength(4);
|
|
85
|
+
expect(hull.some((p) => p.x === 1 && p.y === 1)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('optical corrections', () => {
|
|
90
|
+
test('equal-area circle scale ≈ 1.1284', () => {
|
|
91
|
+
expect(EQUAL_AREA_CIRCLE_SCALE).toBeCloseTo(1.1284, 3);
|
|
92
|
+
expect(equalWeightCircleDiameter(100)).toBeCloseTo(112.84, 1);
|
|
93
|
+
});
|
|
94
|
+
test('overshoot grows extent by ratio', () => {
|
|
95
|
+
expect(overshoot(100, 0.02)).toBeCloseTo(102, 9);
|
|
96
|
+
});
|
|
97
|
+
test('centroidCenter offsets a triangle so its centroid hits the target', () => {
|
|
98
|
+
// Right triangle — bbox center (1,1) differs from centroid (2/3, 2/3).
|
|
99
|
+
const tri: Point[] = [
|
|
100
|
+
{ x: 0, y: 0 },
|
|
101
|
+
{ x: 2, y: 0 },
|
|
102
|
+
{ x: 0, y: 2 },
|
|
103
|
+
];
|
|
104
|
+
const { dx, dy } = centroidCenter(tri, 12, 12);
|
|
105
|
+
const moved = tri.map((p) => ({ x: p.x + dx, y: p.y + dy }));
|
|
106
|
+
const c = centroid(moved);
|
|
107
|
+
expect(c.x).toBeCloseTo(12, 6);
|
|
108
|
+
expect(c.y).toBeCloseTo(12, 6);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('A* connector routing', () => {
|
|
113
|
+
test('clear field routes essentially straight', () => {
|
|
114
|
+
const pts = routeConnector({ x: 0, y: 0 }, { x: 100, y: 0 }, [], { grid: 10 });
|
|
115
|
+
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
|
116
|
+
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 0 });
|
|
117
|
+
expect(pts.every((p) => Math.abs(p.y) < 1e-6)).toBe(true);
|
|
118
|
+
expect(pts.length).toBe(2); // collapsed to a single straight run
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('routes around an obstacle without entering it', () => {
|
|
122
|
+
const obstacle = { x: 40, y: -20, width: 20, height: 40 };
|
|
123
|
+
const pts = routeConnector({ x: 0, y: 0 }, { x: 100, y: 0 }, [obstacle], { grid: 10 });
|
|
124
|
+
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
|
125
|
+
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 0 });
|
|
126
|
+
const inside = (p: Point) =>
|
|
127
|
+
p.x > obstacle.x &&
|
|
128
|
+
p.x < obstacle.x + obstacle.width &&
|
|
129
|
+
p.y > obstacle.y &&
|
|
130
|
+
p.y < obstacle.y + obstacle.height;
|
|
131
|
+
expect(pts.some(inside)).toBe(false);
|
|
132
|
+
expect(pts.length).toBeGreaterThan(2); // had to bend
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('is deterministic', () => {
|
|
136
|
+
const obstacle = { x: 40, y: -20, width: 20, height: 40 };
|
|
137
|
+
const a = routeConnector({ x: 0, y: 0 }, { x: 100, y: 0 }, [obstacle], { grid: 10 });
|
|
138
|
+
const b = routeConnector({ x: 0, y: 0 }, { x: 100, y: 0 }, [obstacle], { grid: 10 });
|
|
139
|
+
expect(a).toEqual(b);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('blobPath (organic closed curve)', () => {
|
|
144
|
+
test('is a closed cubic-Bézier path', () => {
|
|
145
|
+
const d = blobPath(50, 50, 30, { lobes: 7, seed: 3 });
|
|
146
|
+
expect(d.startsWith('M')).toBe(true);
|
|
147
|
+
expect(d).toContain('C');
|
|
148
|
+
expect(d.trimEnd().endsWith('Z')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
test('deterministic for a given seed; different seeds differ', () => {
|
|
151
|
+
expect(blobPath(50, 50, 30, { seed: 7 })).toBe(blobPath(50, 50, 30, { seed: 7 }));
|
|
152
|
+
expect(blobPath(50, 50, 30, { seed: 7 })).not.toBe(blobPath(50, 50, 30, { seed: 8 }));
|
|
153
|
+
});
|
|
154
|
+
test('control points stay within the irregularity envelope of the radius', () => {
|
|
155
|
+
const cx = 100;
|
|
156
|
+
const cy = 100;
|
|
157
|
+
const baseR = 40;
|
|
158
|
+
const irr = 0.3;
|
|
159
|
+
const d = blobPath(cx, cy, baseR, { lobes: 8, irregularity: irr, seed: 5 });
|
|
160
|
+
const nums = Array.from(d.matchAll(/-?\d+\.?\d*/g), (m) => Number(m[0]));
|
|
161
|
+
// coords come in x,y pairs after the M/C letters; check anchor distances
|
|
162
|
+
let maxD = 0;
|
|
163
|
+
for (let i = 0; i + 1 < nums.length; i += 2) {
|
|
164
|
+
maxD = Math.max(maxD, Math.hypot(nums[i] - cx, nums[i + 1] - cy));
|
|
165
|
+
}
|
|
166
|
+
// Bézier control points overshoot anchors a bit; allow generous envelope.
|
|
167
|
+
expect(maxD).toBeLessThan(baseR * (1 + irr) * 1.7);
|
|
168
|
+
expect(maxD).toBeGreaterThan(baseR * 0.5);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('path post-processing', () => {
|
|
173
|
+
test('simplifyCollinear removes mid points on a straight run', () => {
|
|
174
|
+
const out = simplifyCollinear([
|
|
175
|
+
{ x: 0, y: 0 },
|
|
176
|
+
{ x: 1, y: 0 },
|
|
177
|
+
{ x: 2, y: 0 },
|
|
178
|
+
{ x: 2, y: 2 },
|
|
179
|
+
]);
|
|
180
|
+
expect(out).toEqual([
|
|
181
|
+
{ x: 0, y: 0 },
|
|
182
|
+
{ x: 2, y: 0 },
|
|
183
|
+
{ x: 2, y: 2 },
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
test('chamferCorners replaces a corner with two offset points', () => {
|
|
187
|
+
const out = chamferCorners(
|
|
188
|
+
[
|
|
189
|
+
{ x: 0, y: 0 },
|
|
190
|
+
{ x: 10, y: 0 },
|
|
191
|
+
{ x: 10, y: 10 },
|
|
192
|
+
],
|
|
193
|
+
2
|
|
194
|
+
);
|
|
195
|
+
expect(out.length).toBe(4); // 2 endpoints + 2 chamfer points for 1 corner
|
|
196
|
+
expect(out[0]).toEqual({ x: 0, y: 0 });
|
|
197
|
+
expect(out[out.length - 1]).toEqual({ x: 10, y: 10 });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { optimizeSvg } from '../optimize.ts';
|
|
3
|
+
import {
|
|
4
|
+
blurFilter,
|
|
5
|
+
circle,
|
|
6
|
+
clipPath,
|
|
7
|
+
defs,
|
|
8
|
+
dropShadowFilter,
|
|
9
|
+
fe,
|
|
10
|
+
filter,
|
|
11
|
+
grainFilter,
|
|
12
|
+
group,
|
|
13
|
+
linearGradient,
|
|
14
|
+
mask,
|
|
15
|
+
pattern,
|
|
16
|
+
radialGradient,
|
|
17
|
+
rect,
|
|
18
|
+
} from '../primitives.ts';
|
|
19
|
+
import { toJsx, toSvg } from '../serialize.ts';
|
|
20
|
+
|
|
21
|
+
describe('gradients', () => {
|
|
22
|
+
const prims = [
|
|
23
|
+
defs([
|
|
24
|
+
linearGradient({
|
|
25
|
+
id: 'sky',
|
|
26
|
+
x1: 0,
|
|
27
|
+
y1: 0,
|
|
28
|
+
x2: 0,
|
|
29
|
+
y2: 1,
|
|
30
|
+
stops: [
|
|
31
|
+
{ offset: 0, color: '#0b1026' },
|
|
32
|
+
{ offset: 1, color: '#f5a623', opacity: 0.9 },
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
radialGradient({
|
|
36
|
+
id: 'sun',
|
|
37
|
+
cx: 0.5,
|
|
38
|
+
cy: 0.5,
|
|
39
|
+
r: 0.5,
|
|
40
|
+
stops: [
|
|
41
|
+
{ offset: 0, color: '#fff' },
|
|
42
|
+
{ offset: 1, color: '#fff', opacity: 0 },
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
]),
|
|
46
|
+
rect({ x: 0, y: 0, width: 100, height: 60, fill: 'url(#sky)' }),
|
|
47
|
+
];
|
|
48
|
+
const opts = { viewBox: '0 0 100 60' };
|
|
49
|
+
|
|
50
|
+
test('toSvg emits linear + radial gradients with kebab stop-color', () => {
|
|
51
|
+
const svg = toSvg(prims, opts);
|
|
52
|
+
expect(svg).toContain('<linearGradient id="sky"');
|
|
53
|
+
expect(svg).toContain('<radialGradient id="sun"');
|
|
54
|
+
expect(svg).toMatch(/<stop offset="0" stop-color="#0b1026"\s*\/>/);
|
|
55
|
+
expect(svg).toContain('stop-opacity="0.9"');
|
|
56
|
+
expect(svg).toContain('fill="url(#sky)"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('toJsx uses camelCase stopColor / stopOpacity', () => {
|
|
60
|
+
const jsx = toJsx(prims, opts);
|
|
61
|
+
expect(jsx).toContain('stopColor="#0b1026"');
|
|
62
|
+
expect(jsx).toContain('stopOpacity="0.9"');
|
|
63
|
+
expect(jsx).not.toContain('stop-color');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('gradient SVG passes the SVGO validity gate', () => {
|
|
67
|
+
expect(() => optimizeSvg(toSvg(prims, opts))).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('filters / patterns / masks / blend (design toolkit)', () => {
|
|
72
|
+
test('blur + drop-shadow + grain filters serialize with correct fe primitives', () => {
|
|
73
|
+
const prims = [
|
|
74
|
+
defs([
|
|
75
|
+
blurFilter('b', 4),
|
|
76
|
+
dropShadowFilter('sh', { dy: 3, blur: 4 }),
|
|
77
|
+
grainFilter('grain', { opacity: 0.4 }),
|
|
78
|
+
]),
|
|
79
|
+
circle({ cx: 50, cy: 50, r: 20, fill: '#333', filter: 'url(#sh)' }),
|
|
80
|
+
];
|
|
81
|
+
const svg = toSvg(prims, { viewBox: '0 0 100 100' });
|
|
82
|
+
expect(svg).toContain('<feGaussianBlur');
|
|
83
|
+
expect(svg).toContain('stdDeviation="4"');
|
|
84
|
+
expect(svg).toContain('<feDropShadow');
|
|
85
|
+
expect(svg).toContain('flood-opacity="0.3"'); // kebab in SVG dialect
|
|
86
|
+
expect(svg).toContain('<feTurbulence');
|
|
87
|
+
expect(svg).toContain('type="fractalNoise"');
|
|
88
|
+
expect(svg).toContain('filter="url(#sh)"');
|
|
89
|
+
expect(optimizeSvg(svg)).toBeTruthy(); // validity gate
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('JSX dialect: floodColor camelCase + feMerge children', () => {
|
|
93
|
+
const f = filter('m', [
|
|
94
|
+
fe('feGaussianBlur', { stdDeviation: 2, result: 'blur' }),
|
|
95
|
+
fe('feMerge', undefined, [
|
|
96
|
+
fe('feMergeNode', { in: 'blur' }),
|
|
97
|
+
fe('feMergeNode', { in: 'SourceGraphic' }),
|
|
98
|
+
]),
|
|
99
|
+
]);
|
|
100
|
+
const jsx = toJsx([defs([f, dropShadowFilter('sh')])], { viewBox: '0 0 10 10' });
|
|
101
|
+
expect(jsx).toContain('<feMerge>');
|
|
102
|
+
expect(jsx).toContain('<feMergeNode');
|
|
103
|
+
expect(jsx).toContain('floodColor='); // camelCase in JSX
|
|
104
|
+
expect(jsx).not.toContain('flood-color');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('pattern tile fills via url()', () => {
|
|
108
|
+
const prims = [
|
|
109
|
+
defs([
|
|
110
|
+
pattern({
|
|
111
|
+
id: 'dots',
|
|
112
|
+
width: 8,
|
|
113
|
+
height: 8,
|
|
114
|
+
children: [circle({ cx: 2, cy: 2, r: 1, fill: '#888' })],
|
|
115
|
+
}),
|
|
116
|
+
]),
|
|
117
|
+
rect({ x: 0, y: 0, width: 64, height: 64, fill: 'url(#dots)' }),
|
|
118
|
+
];
|
|
119
|
+
const svg = toSvg(prims, { viewBox: '0 0 64 64' });
|
|
120
|
+
expect(svg).toContain('<pattern id="dots" width="8" height="8" patternUnits="userSpaceOnUse"');
|
|
121
|
+
expect(svg).toContain('fill="url(#dots)"');
|
|
122
|
+
expect(optimizeSvg(svg)).toBeTruthy();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('mask + clipPath defs serialize, and mix-blend-mode emits dialect style', () => {
|
|
126
|
+
const prims = [
|
|
127
|
+
defs([
|
|
128
|
+
mask('fade', [rect({ x: 0, y: 0, width: 100, height: 100, fill: 'url(#g)' })]),
|
|
129
|
+
clipPath('round', [circle({ cx: 50, cy: 50, r: 40 })]),
|
|
130
|
+
]),
|
|
131
|
+
group([rect({ x: 0, y: 0, width: 100, height: 100, fill: '#f00' })], {}),
|
|
132
|
+
rect({
|
|
133
|
+
x: 0,
|
|
134
|
+
y: 0,
|
|
135
|
+
width: 100,
|
|
136
|
+
height: 100,
|
|
137
|
+
fill: '#0f0',
|
|
138
|
+
mask: 'url(#fade)',
|
|
139
|
+
clipPath: 'url(#round)',
|
|
140
|
+
mixBlendMode: 'overlay',
|
|
141
|
+
}),
|
|
142
|
+
];
|
|
143
|
+
const svg = toSvg(prims, { viewBox: '0 0 100 100' });
|
|
144
|
+
expect(svg).toContain('<mask id="fade">');
|
|
145
|
+
expect(svg).toContain('<clipPath id="round">');
|
|
146
|
+
expect(svg).toContain('mask="url(#fade)"');
|
|
147
|
+
expect(svg).toContain('clip-path="url(#round)"'); // kebab in SVG
|
|
148
|
+
expect(svg).toContain('style="mix-blend-mode:overlay"');
|
|
149
|
+
const jsx = toJsx(prims, { viewBox: '0 0 100 100' });
|
|
150
|
+
expect(jsx).toContain('clipPath="url(#round)"'); // camelCase in JSX
|
|
151
|
+
expect(jsx).toContain('style={{ mixBlendMode: "overlay" }}');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('group carries the compositing surface (filter + mask + blend)', () => {
|
|
155
|
+
const g = group([circle({ cx: 10, cy: 10, r: 5 })], {
|
|
156
|
+
filter: 'url(#warp)',
|
|
157
|
+
mask: 'url(#fade)',
|
|
158
|
+
mixBlendMode: 'screen',
|
|
159
|
+
});
|
|
160
|
+
const svg = toSvg([g], { viewBox: '0 0 20 20' });
|
|
161
|
+
expect(svg).toMatch(/<g[^>]*filter="url\(#warp\)"/);
|
|
162
|
+
expect(svg).toContain('mask="url(#fade)"');
|
|
163
|
+
expect(svg).toContain('style="mix-blend-mode:screen"');
|
|
164
|
+
// a group must never leak a stroke default (it's not a fillable shape)
|
|
165
|
+
expect(svg).not.toMatch(/<g[^>]*stroke=/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { Rect } from '../geometry.ts';
|
|
3
|
+
import { type LabelItem, diagram, modularScale, placeLabels, snapToGrid } from '../layout.ts';
|
|
4
|
+
import type { Point } from '../primitives.ts';
|
|
5
|
+
|
|
6
|
+
describe('grid + modular scale', () => {
|
|
7
|
+
test('snapToGrid', () => {
|
|
8
|
+
expect(snapToGrid({ x: 13, y: 7 }, 8)).toEqual({ x: 16, y: 8 });
|
|
9
|
+
});
|
|
10
|
+
test('modularScale builds a ratio ladder', () => {
|
|
11
|
+
expect(modularScale(16, 1.5, 3)).toEqual([16, 24, 36]);
|
|
12
|
+
expect(modularScale(16, 1.25, 4)[0]).toBe(16);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('constraint-based label placement', () => {
|
|
17
|
+
test('places N labels around spread anchors with no overlap', () => {
|
|
18
|
+
const items: LabelItem[] = [
|
|
19
|
+
{ id: 'a', anchor: { x: 0, y: 0 }, width: 30, height: 12 },
|
|
20
|
+
{ id: 'b', anchor: { x: 200, y: 0 }, width: 30, height: 12 },
|
|
21
|
+
{ id: 'c', anchor: { x: 0, y: 200 }, width: 30, height: 12 },
|
|
22
|
+
{ id: 'd', anchor: { x: 200, y: 200 }, width: 30, height: 12 },
|
|
23
|
+
];
|
|
24
|
+
const placements = placeLabels(items, { gap: 6 });
|
|
25
|
+
expect(placements).toHaveLength(4);
|
|
26
|
+
// No two placed boxes overlap (plenty of room → solver should find 0 overlap).
|
|
27
|
+
for (let i = 0; i < placements.length; i++) {
|
|
28
|
+
for (let j = i + 1; j < placements.length; j++) {
|
|
29
|
+
const a = placements[i];
|
|
30
|
+
const b = placements[j];
|
|
31
|
+
const ox = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
|
|
32
|
+
const oy = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
|
|
33
|
+
expect(ox * oy).toBe(0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
test('is deterministic', () => {
|
|
38
|
+
const items: LabelItem[] = [
|
|
39
|
+
{ id: 'x', anchor: { x: 10, y: 10 }, width: 20, height: 10 },
|
|
40
|
+
{ id: 'y', anchor: { x: 14, y: 12 }, width: 20, height: 10 },
|
|
41
|
+
];
|
|
42
|
+
expect(placeLabels(items)).toEqual(placeLabels(items));
|
|
43
|
+
});
|
|
44
|
+
test('returns placements in input order', () => {
|
|
45
|
+
const items: LabelItem[] = [
|
|
46
|
+
{ id: 'z', anchor: { x: 0, y: 0 }, width: 10, height: 10 },
|
|
47
|
+
{ id: 'a', anchor: { x: 100, y: 0 }, width: 10, height: 10 },
|
|
48
|
+
];
|
|
49
|
+
expect(placeLabels(items).map((p) => p.id)).toEqual(['z', 'a']);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('diagram()', () => {
|
|
54
|
+
const nodes = [
|
|
55
|
+
{ id: 'n1', rect: { x: 0, y: 0, width: 60, height: 30 } as Rect, label: 'A' },
|
|
56
|
+
{ id: 'n2', rect: { x: 200, y: 0, width: 60, height: 30 } as Rect, label: 'B' },
|
|
57
|
+
{ id: 'n3', rect: { x: 0, y: 150, width: 60, height: 30 } as Rect, label: 'C' },
|
|
58
|
+
{ id: 'n4', rect: { x: 200, y: 150, width: 60, height: 30 } as Rect, label: 'D' },
|
|
59
|
+
];
|
|
60
|
+
const edges = [
|
|
61
|
+
{ from: 'n1', to: 'n2' },
|
|
62
|
+
{ from: 'n1', to: 'n4' },
|
|
63
|
+
{ from: 'n3', to: 'n4' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
test('emits an edges group and a nodes group', () => {
|
|
67
|
+
const prims = diagram(nodes, edges, { grid: 10 });
|
|
68
|
+
expect(prims).toHaveLength(2);
|
|
69
|
+
expect(prims[0]).toMatchObject({ el: 'group', id: 'edges' });
|
|
70
|
+
expect(prims[1]).toMatchObject({ el: 'group', id: 'nodes' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('renders one rect + one label per node', () => {
|
|
74
|
+
const prims = diagram(nodes, edges, { grid: 10 }) as Array<{ children: Array<{ el: string }> }>;
|
|
75
|
+
const nodeChildren = prims[1].children;
|
|
76
|
+
expect(nodeChildren.filter((c) => c.el === 'rect')).toHaveLength(4);
|
|
77
|
+
expect(nodeChildren.filter((c) => c.el === 'text')).toHaveLength(4);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('routes edges around NON-endpoint node boxes (never crosses a third node)', () => {
|
|
81
|
+
const prims = diagram(nodes, edges, { grid: 10, padding: 4 }) as Array<{
|
|
82
|
+
children: Array<{ el: string; points?: Point[] }>;
|
|
83
|
+
}>;
|
|
84
|
+
const edgePolys = prims[0].children.filter((c) => c.el === 'polyline');
|
|
85
|
+
expect(edgePolys.length).toBe(3);
|
|
86
|
+
const inside = (p: Point, r: Rect) =>
|
|
87
|
+
p.x > r.x && p.x < r.x + r.width && p.y > r.y && p.y < r.y + r.height;
|
|
88
|
+
// Endpoints sit at node CENTERS (inside from/to boxes by construction); the
|
|
89
|
+
// guarantee is that waypoints never enter a node that isn't this edge's
|
|
90
|
+
// own endpoint.
|
|
91
|
+
edges.forEach((e, i) => {
|
|
92
|
+
const obstacles = nodes.filter((n) => n.id !== e.from && n.id !== e.to);
|
|
93
|
+
for (const pt of edgePolys[i].points ?? []) {
|
|
94
|
+
for (const n of obstacles) {
|
|
95
|
+
expect(inside(pt, n.rect)).toBe(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('index barrel', () => {
|
|
103
|
+
test('re-exports the full public surface', async () => {
|
|
104
|
+
const m = await import('../index.ts');
|
|
105
|
+
for (const name of [
|
|
106
|
+
'rect',
|
|
107
|
+
'circle',
|
|
108
|
+
'place',
|
|
109
|
+
'toSvg',
|
|
110
|
+
'toJsx',
|
|
111
|
+
'optimizeSvg',
|
|
112
|
+
'diagram',
|
|
113
|
+
'contrastRatio',
|
|
114
|
+
'routeConnector',
|
|
115
|
+
'oklchToRgb',
|
|
116
|
+
]) {
|
|
117
|
+
expect(typeof (m as Record<string, unknown>)[name]).toBe('function');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { isValidSvg, optimizeSvg } from '../optimize.ts';
|
|
3
|
+
import { rect } from '../primitives.ts';
|
|
4
|
+
import { toSvg } from '../serialize.ts';
|
|
5
|
+
|
|
6
|
+
describe('optimizeSvg', () => {
|
|
7
|
+
test('round-trips a known mark and stays valid', () => {
|
|
8
|
+
const svg = toSvg([rect({ x: 2, y: 2, width: 20, height: 20, fill: '#aabbcc' })], {
|
|
9
|
+
viewBox: '0 0 24 24',
|
|
10
|
+
a11y: { title: 'Square', desc: 'A rounded square' },
|
|
11
|
+
});
|
|
12
|
+
const out = optimizeSvg(svg);
|
|
13
|
+
expect(out).toContain('viewBox="0 0 24 24"');
|
|
14
|
+
expect(out).toContain('<title>Square</title>');
|
|
15
|
+
expect(out).toContain('<desc>A rounded square</desc>');
|
|
16
|
+
expect(isValidSvg(out)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('preserves the a11y + scaling contract (viewBox / title / desc kept)', () => {
|
|
20
|
+
const src =
|
|
21
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>T</title><desc>D</desc><circle cx="5" cy="5" r="4"/></svg>';
|
|
22
|
+
const out = optimizeSvg(src);
|
|
23
|
+
expect(out).toContain('viewBox="0 0 10 10"');
|
|
24
|
+
expect(out).toContain('<title>T</title>');
|
|
25
|
+
expect(out).toContain('<desc>D</desc>');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('shrinks coordinate precision', () => {
|
|
29
|
+
const src =
|
|
30
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M1.23456789 2 L4 4"/></svg>';
|
|
31
|
+
const out = optimizeSvg(src, { floatPrecision: 2 });
|
|
32
|
+
expect(out).not.toContain('1.23456789');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('throws on malformed SVG (the validity gate)', () => {
|
|
36
|
+
expect(() => optimizeSvg('<svg><rect')).toThrow(/not valid SVG/);
|
|
37
|
+
expect(isValidSvg('<svg><rect')).toBe(false);
|
|
38
|
+
expect(isValidSvg('not svg at all <<<')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|