@zseven-w/pen-core 0.6.0 → 0.7.1
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/LICENSE +21 -0
- package/README.md +177 -25
- package/package.json +19 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +131 -142
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +75 -81
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +98 -106
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
|
+
import { normalizeStrokeFillSchema } from '../normalize/normalize-stroke-fill-schema';
|
|
4
|
+
|
|
5
|
+
// Test helpers accept untyped property bags because most assertions here
|
|
6
|
+
// intentionally put malformed (schema-violating) data on the node — that
|
|
7
|
+
// is exactly the input shape the normalizer is designed to repair. A
|
|
8
|
+
// strongly-typed signature would reject the very cases we need to cover.
|
|
9
|
+
type NodeProps = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
const path = (props: NodeProps = {}): PenNode =>
|
|
12
|
+
({
|
|
13
|
+
id: 'p',
|
|
14
|
+
type: 'path',
|
|
15
|
+
d: 'M0 0 L100 0',
|
|
16
|
+
width: 100,
|
|
17
|
+
height: 50,
|
|
18
|
+
...props,
|
|
19
|
+
}) as unknown as PenNode;
|
|
20
|
+
|
|
21
|
+
const ellipse = (props: NodeProps = {}): PenNode =>
|
|
22
|
+
({
|
|
23
|
+
id: 'e',
|
|
24
|
+
type: 'ellipse',
|
|
25
|
+
width: 100,
|
|
26
|
+
height: 100,
|
|
27
|
+
...props,
|
|
28
|
+
}) as unknown as PenNode;
|
|
29
|
+
|
|
30
|
+
const frame = (props: NodeProps & { children?: PenNode[] } = {}): PenNode =>
|
|
31
|
+
({
|
|
32
|
+
id: 'f',
|
|
33
|
+
type: 'frame',
|
|
34
|
+
width: 200,
|
|
35
|
+
height: 200,
|
|
36
|
+
...props,
|
|
37
|
+
}) as unknown as PenNode;
|
|
38
|
+
|
|
39
|
+
const text = (props: NodeProps = {}): PenNode =>
|
|
40
|
+
({
|
|
41
|
+
id: 't',
|
|
42
|
+
type: 'text',
|
|
43
|
+
content: 'Hello',
|
|
44
|
+
fontSize: 16,
|
|
45
|
+
...props,
|
|
46
|
+
}) as unknown as PenNode;
|
|
47
|
+
|
|
48
|
+
const iconFont = (props: NodeProps = {}): PenNode =>
|
|
49
|
+
({
|
|
50
|
+
id: 'i',
|
|
51
|
+
type: 'icon_font',
|
|
52
|
+
iconFontName: 'heart',
|
|
53
|
+
width: 16,
|
|
54
|
+
height: 16,
|
|
55
|
+
...props,
|
|
56
|
+
}) as unknown as PenNode;
|
|
57
|
+
|
|
58
|
+
const validFill = [{ type: 'solid' as const, color: '#C4F82A' }];
|
|
59
|
+
|
|
60
|
+
describe('normalizeStrokeFillSchema — stroke array unwrap', () => {
|
|
61
|
+
it('unwraps a stroke that is an array of one proper PenStroke object', () => {
|
|
62
|
+
const node = ellipse({
|
|
63
|
+
stroke: [{ thickness: 12, fill: validFill }],
|
|
64
|
+
});
|
|
65
|
+
normalizeStrokeFillSchema(node);
|
|
66
|
+
const rec = node as unknown as { stroke?: { thickness?: number; fill?: unknown } };
|
|
67
|
+
expect(Array.isArray(rec.stroke)).toBe(false);
|
|
68
|
+
expect(rec.stroke?.thickness).toBe(12);
|
|
69
|
+
expect(rec.stroke?.fill).toEqual(validFill);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('leaves a stroke that is already a proper object alone', () => {
|
|
73
|
+
const node = ellipse({
|
|
74
|
+
stroke: { thickness: 4, fill: validFill },
|
|
75
|
+
});
|
|
76
|
+
normalizeStrokeFillSchema(node);
|
|
77
|
+
const rec = node as unknown as { stroke?: { thickness?: number; fill?: unknown } };
|
|
78
|
+
expect(rec.stroke?.thickness).toBe(4);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('converts a fill-shaped stroke entry into a proper PenStroke', () => {
|
|
82
|
+
// Real M2.7 failure: stroke is an array and the inner object has
|
|
83
|
+
// {type, color} (fill shape) instead of {thickness, fill}. strokeWidth
|
|
84
|
+
// lives as a top-level node field.
|
|
85
|
+
const node = path({
|
|
86
|
+
stroke: [{ type: 'solid', color: '#C4F82A' }],
|
|
87
|
+
strokeWidth: 2.5,
|
|
88
|
+
});
|
|
89
|
+
normalizeStrokeFillSchema(node);
|
|
90
|
+
const rec = node as unknown as {
|
|
91
|
+
stroke?: { thickness?: number; fill?: Array<{ color?: string }> };
|
|
92
|
+
strokeWidth?: number;
|
|
93
|
+
};
|
|
94
|
+
expect(rec.stroke?.thickness).toBe(2.5);
|
|
95
|
+
expect(rec.stroke?.fill?.[0]?.color).toBe('#C4F82A');
|
|
96
|
+
// Stray strokeWidth field is cleaned up after migration
|
|
97
|
+
expect(rec.strokeWidth).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uses a default thickness when neither strokeWidth nor thickness is present', () => {
|
|
101
|
+
const node = path({
|
|
102
|
+
stroke: [{ type: 'solid', color: '#111' }],
|
|
103
|
+
});
|
|
104
|
+
normalizeStrokeFillSchema(node);
|
|
105
|
+
const rec = node as unknown as { stroke?: { thickness?: number; fill?: unknown } };
|
|
106
|
+
expect(rec.stroke?.thickness).toBeGreaterThan(0);
|
|
107
|
+
expect(rec.stroke?.fill).toBeDefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('handles an object-shaped stroke with a fill-shaped inner value', () => {
|
|
111
|
+
// Some sub-agents emit { stroke: { type: "solid", color: "#fff" } }
|
|
112
|
+
// (object, not array) — still a fill shape. Same recovery rules apply.
|
|
113
|
+
const node = path({
|
|
114
|
+
stroke: { type: 'solid', color: '#FFFFFF' },
|
|
115
|
+
strokeWidth: 3,
|
|
116
|
+
});
|
|
117
|
+
normalizeStrokeFillSchema(node);
|
|
118
|
+
const rec = node as unknown as {
|
|
119
|
+
stroke?: { thickness?: number; fill?: Array<{ color?: string }> };
|
|
120
|
+
};
|
|
121
|
+
expect(rec.stroke?.thickness).toBe(3);
|
|
122
|
+
expect(rec.stroke?.fill?.[0]?.color).toBe('#FFFFFF');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('migrates SVG-style stroke attrs into a canonical PenStroke', () => {
|
|
126
|
+
const node = path({
|
|
127
|
+
name: 'HR Chart Line',
|
|
128
|
+
'stroke-width': '2',
|
|
129
|
+
'stroke-dasharray': '4 2',
|
|
130
|
+
'stroke-linecap': 'round',
|
|
131
|
+
'stroke-linejoin': 'round',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
normalizeStrokeFillSchema(node);
|
|
135
|
+
|
|
136
|
+
const rec = node as unknown as {
|
|
137
|
+
stroke?: {
|
|
138
|
+
thickness?: number;
|
|
139
|
+
dashPattern?: number[];
|
|
140
|
+
dashOffset?: number;
|
|
141
|
+
cap?: string;
|
|
142
|
+
join?: string;
|
|
143
|
+
fill?: Array<{ color?: string }>;
|
|
144
|
+
};
|
|
145
|
+
'stroke-width'?: unknown;
|
|
146
|
+
'stroke-dasharray'?: unknown;
|
|
147
|
+
'stroke-dashoffset'?: unknown;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
expect(rec.stroke?.thickness).toBe(2);
|
|
151
|
+
expect(rec.stroke?.dashPattern).toEqual([4, 2]);
|
|
152
|
+
expect(rec.stroke?.dashOffset).toBeUndefined();
|
|
153
|
+
expect(rec.stroke?.cap).toBe('round');
|
|
154
|
+
expect(rec.stroke?.join).toBe('round');
|
|
155
|
+
expect(rec.stroke?.fill?.[0]?.color).toBe('#22C55E');
|
|
156
|
+
expect(rec['stroke-width']).toBeUndefined();
|
|
157
|
+
expect(rec['stroke-dasharray']).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('migrates SVG dash offset into PenStroke.dashOffset', () => {
|
|
161
|
+
const node = path({
|
|
162
|
+
name: 'Steps Progress',
|
|
163
|
+
'stroke-width': '8',
|
|
164
|
+
'stroke-dasharray': '158.4',
|
|
165
|
+
'stroke-dashoffset': '44.4',
|
|
166
|
+
'stroke-linecap': 'round',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
normalizeStrokeFillSchema(node);
|
|
170
|
+
|
|
171
|
+
const rec = node as unknown as {
|
|
172
|
+
stroke?: {
|
|
173
|
+
thickness?: number;
|
|
174
|
+
dashPattern?: number[];
|
|
175
|
+
dashOffset?: number;
|
|
176
|
+
cap?: string;
|
|
177
|
+
fill?: Array<{ color?: string }>;
|
|
178
|
+
};
|
|
179
|
+
'stroke-dashoffset'?: unknown;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
expect(rec.stroke?.thickness).toBe(8);
|
|
183
|
+
expect(rec.stroke?.dashPattern).toEqual([158.4, 158.4]);
|
|
184
|
+
expect(rec.stroke?.dashOffset).toBe(44.4);
|
|
185
|
+
expect(rec.stroke?.cap).toBe('round');
|
|
186
|
+
expect(rec.stroke?.fill?.[0]?.color).toBe('#22C55E');
|
|
187
|
+
expect(rec['stroke-dashoffset']).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('infers a muted track color for SVG-style track paths with no explicit stroke fill', () => {
|
|
191
|
+
const node = path({
|
|
192
|
+
name: 'Steps Track',
|
|
193
|
+
'stroke-width': '8',
|
|
194
|
+
fill: [{ type: 'solid', color: '#00000000' }],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
normalizeStrokeFillSchema(node);
|
|
198
|
+
|
|
199
|
+
const rec = node as unknown as {
|
|
200
|
+
stroke?: { fill?: Array<{ color?: string }> };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
expect(rec.stroke?.fill?.[0]?.color).toBe('#2A2A2A');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('normalizeStrokeFillSchema — illegal fill color replacement', () => {
|
|
208
|
+
it('replaces a sole "none" fill with explicit transparent hex', () => {
|
|
209
|
+
// "none" and "transparent" are CSS keywords, not valid PenFill colors.
|
|
210
|
+
// But they express an explicit "no fill" intent (e.g. an activity ring
|
|
211
|
+
// that should be hollow). Deleting the fill field entirely would make
|
|
212
|
+
// the canvas renderer fall back to a default gray fill — the opposite
|
|
213
|
+
// of what the AI meant. Replace with the 8-digit transparent hex so
|
|
214
|
+
// the renderer sees an explicit transparent color and respects it.
|
|
215
|
+
const node = path({
|
|
216
|
+
fill: [{ type: 'solid', color: 'none' }],
|
|
217
|
+
});
|
|
218
|
+
normalizeStrokeFillSchema(node);
|
|
219
|
+
const rec = node as unknown as { fill?: Array<{ type?: string; color?: string }> };
|
|
220
|
+
expect(rec.fill).toBeDefined();
|
|
221
|
+
expect(rec.fill).toHaveLength(1);
|
|
222
|
+
expect(rec.fill?.[0]?.type).toBe('solid');
|
|
223
|
+
expect(rec.fill?.[0]?.color?.toLowerCase()).toBe('#00000000');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('replaces a sole "transparent" fill with explicit transparent hex', () => {
|
|
227
|
+
const node = path({
|
|
228
|
+
fill: [{ type: 'solid', color: 'transparent' }],
|
|
229
|
+
});
|
|
230
|
+
normalizeStrokeFillSchema(node);
|
|
231
|
+
const rec = node as unknown as { fill?: Array<{ type?: string; color?: string }> };
|
|
232
|
+
expect(rec.fill).toBeDefined();
|
|
233
|
+
expect(rec.fill).toHaveLength(1);
|
|
234
|
+
expect(rec.fill?.[0]?.color?.toLowerCase()).toBe('#00000000');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('drops a "none" entry but keeps other legitimate entries in the same array', () => {
|
|
238
|
+
// Mixed array: one illegal, one legal. Just drop the illegal one and
|
|
239
|
+
// keep the real color — no transparent fallback needed.
|
|
240
|
+
const node = path({
|
|
241
|
+
fill: [
|
|
242
|
+
{ type: 'solid', color: 'none' },
|
|
243
|
+
{ type: 'solid', color: '#FF0000' },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
normalizeStrokeFillSchema(node);
|
|
247
|
+
const rec = node as unknown as { fill?: Array<{ color?: string }> };
|
|
248
|
+
expect(rec.fill).toHaveLength(1);
|
|
249
|
+
expect(rec.fill?.[0]?.color).toBe('#FF0000');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('keeps legitimate hex fills alone', () => {
|
|
253
|
+
const node = path({
|
|
254
|
+
fill: [{ type: 'solid', color: '#C4F82A' }],
|
|
255
|
+
});
|
|
256
|
+
normalizeStrokeFillSchema(node);
|
|
257
|
+
const rec = node as unknown as { fill?: Array<{ color?: string }> };
|
|
258
|
+
expect(rec.fill?.[0]?.color).toBe('#C4F82A');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('keeps 8-digit transparent hex (#00000000) alone', () => {
|
|
262
|
+
// The 8-digit hex IS a valid color string (alpha channel). Only the
|
|
263
|
+
// CSS keywords "none" and "transparent" are the unsupported forms.
|
|
264
|
+
const node = ellipse({
|
|
265
|
+
fill: [{ type: 'solid', color: '#00000000' }],
|
|
266
|
+
});
|
|
267
|
+
normalizeStrokeFillSchema(node);
|
|
268
|
+
const rec = node as unknown as { fill?: Array<{ color?: string }> };
|
|
269
|
+
expect(rec.fill?.[0]?.color).toBe('#00000000');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('DELETES text node fill when all entries are CSS keywords', () => {
|
|
273
|
+
// text.fill is the foreground color, not a shape background.
|
|
274
|
+
// "none"/"transparent" on a text node would hide the text entirely —
|
|
275
|
+
// almost certainly a mistake. Delete the field so downstream layers
|
|
276
|
+
// (role defaults, button contrast heuristic) can fill in a visible
|
|
277
|
+
// color. Replacing with #00000000 would freeze the text invisible.
|
|
278
|
+
const node = text({
|
|
279
|
+
fill: [{ type: 'solid', color: 'none' }],
|
|
280
|
+
});
|
|
281
|
+
normalizeStrokeFillSchema(node);
|
|
282
|
+
const rec = node as unknown as { fill?: unknown };
|
|
283
|
+
expect(rec.fill).toBeUndefined();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('DELETES text node fill for "transparent" too', () => {
|
|
287
|
+
const node = text({
|
|
288
|
+
fill: [{ type: 'solid', color: 'transparent' }],
|
|
289
|
+
});
|
|
290
|
+
normalizeStrokeFillSchema(node);
|
|
291
|
+
const rec = node as unknown as { fill?: unknown };
|
|
292
|
+
expect(rec.fill).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('DELETES icon_font fill when all entries are CSS keywords', () => {
|
|
296
|
+
// Same reasoning: icon_font.fill is the foreground color.
|
|
297
|
+
const node = iconFont({
|
|
298
|
+
fill: [{ type: 'solid', color: 'none' }],
|
|
299
|
+
});
|
|
300
|
+
normalizeStrokeFillSchema(node);
|
|
301
|
+
const rec = node as unknown as { fill?: unknown };
|
|
302
|
+
expect(rec.fill).toBeUndefined();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('still keeps a legitimate text fill unchanged', () => {
|
|
306
|
+
const node = text({
|
|
307
|
+
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
|
308
|
+
});
|
|
309
|
+
normalizeStrokeFillSchema(node);
|
|
310
|
+
const rec = node as unknown as { fill?: Array<{ color?: string }> };
|
|
311
|
+
expect(rec.fill?.[0]?.color).toBe('#FFFFFF');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('still drops illegal entries from text but preserves legal siblings', () => {
|
|
315
|
+
const node = text({
|
|
316
|
+
fill: [
|
|
317
|
+
{ type: 'solid', color: 'none' },
|
|
318
|
+
{ type: 'solid', color: '#00FF00' },
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
normalizeStrokeFillSchema(node);
|
|
322
|
+
const rec = node as unknown as { fill?: Array<{ color?: string }> };
|
|
323
|
+
expect(rec.fill).toHaveLength(1);
|
|
324
|
+
expect(rec.fill?.[0]?.color).toBe('#00FF00');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('keeps the transparent-hex replacement for shape fills (frame / ellipse / path)', () => {
|
|
328
|
+
// Sanity guard: the shape-vs-foreground split must not regress the
|
|
329
|
+
// shape branch. Frames, ellipses, and paths still get the explicit
|
|
330
|
+
// #00000000 when an AI writes "none".
|
|
331
|
+
for (const node of [
|
|
332
|
+
frame({ fill: [{ type: 'solid', color: 'none' }] }),
|
|
333
|
+
ellipse({ fill: [{ type: 'solid', color: 'none' }] }),
|
|
334
|
+
path({ fill: [{ type: 'solid', color: 'transparent' }] }),
|
|
335
|
+
]) {
|
|
336
|
+
normalizeStrokeFillSchema(node);
|
|
337
|
+
const rec = node as unknown as { fill?: Array<{ color?: string }> };
|
|
338
|
+
expect(rec.fill).toHaveLength(1);
|
|
339
|
+
expect(rec.fill?.[0]?.color?.toLowerCase()).toBe('#00000000');
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('also strips illegal colors from stroke.fill arrays', () => {
|
|
344
|
+
const node = path({
|
|
345
|
+
stroke: { thickness: 2, fill: [{ type: 'solid', color: 'none' }] },
|
|
346
|
+
});
|
|
347
|
+
normalizeStrokeFillSchema(node);
|
|
348
|
+
const rec = node as unknown as { stroke?: { thickness?: number; fill?: unknown[] } };
|
|
349
|
+
// Whole stroke becomes either unset, or stroke.fill is empty — either
|
|
350
|
+
// way the renderer will not try to use "none".
|
|
351
|
+
if (rec.stroke && Array.isArray(rec.stroke.fill)) {
|
|
352
|
+
expect(rec.stroke.fill.length).toBe(0);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('normalizeStrokeFillSchema — recursion', () => {
|
|
358
|
+
it('recurses into children', () => {
|
|
359
|
+
const inner = ellipse({
|
|
360
|
+
id: 'inner',
|
|
361
|
+
stroke: [{ thickness: 8, fill: validFill }],
|
|
362
|
+
});
|
|
363
|
+
const root = frame({ id: 'root', children: [inner] });
|
|
364
|
+
normalizeStrokeFillSchema(root);
|
|
365
|
+
const rec = inner as unknown as { stroke?: { thickness?: number } };
|
|
366
|
+
expect(Array.isArray((inner as unknown as { stroke?: unknown }).stroke)).toBe(false);
|
|
367
|
+
expect(rec.stroke?.thickness).toBe(8);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('reproduces the M2.7 activity rings case end-to-end', () => {
|
|
371
|
+
const ring = ellipse({
|
|
372
|
+
id: 'ring',
|
|
373
|
+
name: 'Steps Circle',
|
|
374
|
+
width: 100,
|
|
375
|
+
height: 100,
|
|
376
|
+
fill: [{ type: 'solid', color: '#00000000' }],
|
|
377
|
+
stroke: [{ thickness: 12, fill: [{ type: 'solid', color: '#C4F82A' }] }],
|
|
378
|
+
});
|
|
379
|
+
normalizeStrokeFillSchema(ring);
|
|
380
|
+
const rec = ring as unknown as {
|
|
381
|
+
fill?: Array<{ color?: string }>;
|
|
382
|
+
stroke?: { thickness?: number; fill?: Array<{ color?: string }> };
|
|
383
|
+
};
|
|
384
|
+
// Stroke is now a proper object with the original thickness and color
|
|
385
|
+
expect(rec.stroke?.thickness).toBe(12);
|
|
386
|
+
expect(rec.stroke?.fill?.[0]?.color).toBe('#C4F82A');
|
|
387
|
+
// Transparent 8-digit hex fill is preserved (it's not a CSS keyword)
|
|
388
|
+
expect(rec.fill?.[0]?.color).toBe('#00000000');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('reproduces the M2.7 heart-rate chart line case end-to-end', () => {
|
|
392
|
+
const line = path({
|
|
393
|
+
id: 'line',
|
|
394
|
+
name: 'Chart Line',
|
|
395
|
+
d: 'M0 50 L100 20',
|
|
396
|
+
fill: [{ type: 'solid', color: 'none' }],
|
|
397
|
+
stroke: [{ type: 'solid', color: '#C4F82A' }],
|
|
398
|
+
strokeWidth: 2.5,
|
|
399
|
+
});
|
|
400
|
+
normalizeStrokeFillSchema(line);
|
|
401
|
+
const rec = line as unknown as {
|
|
402
|
+
fill?: Array<{ color?: string }>;
|
|
403
|
+
stroke?: { thickness?: number; fill?: Array<{ color?: string }> };
|
|
404
|
+
strokeWidth?: number;
|
|
405
|
+
};
|
|
406
|
+
expect(rec.stroke?.thickness).toBe(2.5);
|
|
407
|
+
expect(rec.stroke?.fill?.[0]?.color).toBe('#C4F82A');
|
|
408
|
+
// "none" fill was replaced with explicit transparent — NOT deleted.
|
|
409
|
+
// Deleting would let the canvas factory fall back to the default gray
|
|
410
|
+
// fill and the chart line's background would bleed through as a
|
|
411
|
+
// solid grey rectangle.
|
|
412
|
+
expect(rec.fill).toBeDefined();
|
|
413
|
+
expect(rec.fill?.[0]?.color?.toLowerCase()).toBe('#00000000');
|
|
414
|
+
expect(rec.strokeWidth).toBeUndefined();
|
|
415
|
+
});
|
|
416
|
+
});
|