@zseven-w/pen-renderer 0.6.0 → 0.7.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.
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { hasVisibleStroke, shouldUseTransparentFallbackFill } from '../paint-utils';
3
+
4
+ describe('hasVisibleStroke', () => {
5
+ it('returns true for a normal colored stroke', () => {
6
+ expect(
7
+ hasVisibleStroke({
8
+ thickness: 4,
9
+ fill: [{ type: 'solid', color: '#60A5FA' }],
10
+ }),
11
+ ).toBe(true);
12
+ });
13
+
14
+ it('returns false when stroke is missing a visible color', () => {
15
+ expect(
16
+ hasVisibleStroke({
17
+ thickness: 4,
18
+ fill: [{ type: 'solid', color: '#00000000' }],
19
+ }),
20
+ ).toBe(true);
21
+ expect(
22
+ hasVisibleStroke({
23
+ thickness: 4,
24
+ fill: [],
25
+ }),
26
+ ).toBe(false);
27
+ });
28
+
29
+ it('returns false when stroke width resolves to zero', () => {
30
+ expect(
31
+ hasVisibleStroke({
32
+ thickness: 0,
33
+ fill: [{ type: 'solid', color: '#60A5FA' }],
34
+ }),
35
+ ).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe('shouldUseTransparentFallbackFill', () => {
40
+ it('keeps stroke-only shapes hollow instead of falling back to default fill', () => {
41
+ expect(
42
+ shouldUseTransparentFallbackFill(undefined, {
43
+ thickness: 12,
44
+ fill: [{ type: 'solid', color: '#22C55E' }],
45
+ }),
46
+ ).toBe(true);
47
+ });
48
+
49
+ it('keeps fill-less containers transparent', () => {
50
+ expect(shouldUseTransparentFallbackFill(undefined, undefined, true)).toBe(true);
51
+ });
52
+
53
+ it('does not override explicit fills', () => {
54
+ expect(
55
+ shouldUseTransparentFallbackFill([{ type: 'solid', color: '#00000000' }], {
56
+ thickness: 4,
57
+ fill: [{ type: 'solid', color: '#60A5FA' }],
58
+ }),
59
+ ).toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,312 @@
1
+ // packages/pen-renderer/src/__tests__/render-node-thumbnail.test.ts
2
+ //
3
+ // Output-shape and fallback tests for renderNodeThumbnail. We do NOT test
4
+ // pixel-perfect output (requires a real CanvasKit WASM instance). Instead we
5
+ // verify:
6
+ // 1. Returns null gracefully when CanvasKit is unavailable (test env)
7
+ // 2. Returns null for invalid / null inputs
8
+ // 3. Returns null when size is invalid
9
+ // 4. The function is exported and callable
10
+ // 5. resolveNodeForCanvas is called with document variables + active theme (I6)
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+ import type { PenDocument, PenNode } from '@zseven-w/pen-types';
14
+ import * as penCore from '@zseven-w/pen-core';
15
+
16
+ // Mock @zseven-w/pen-core to allow spying on resolveNodeForCanvas while
17
+ // keeping all other exports from the real module intact.
18
+ vi.mock('@zseven-w/pen-core', async () => {
19
+ const actual = await vi.importActual<typeof penCore>('@zseven-w/pen-core');
20
+ return { ...actual };
21
+ });
22
+
23
+ import { renderNodeThumbnail } from '../render-node-thumbnail';
24
+
25
+ // CanvasKit WASM is NOT available in the vitest/jsdom environment.
26
+ // getCanvasKit() returns null, so renderNodeThumbnail must fall back to null
27
+ // for ALL inputs. These tests assert the graceful-fallback contract.
28
+
29
+ const makeRect = (id = 'rect-1'): PenNode =>
30
+ ({
31
+ id,
32
+ type: 'rectangle',
33
+ x: 0,
34
+ y: 0,
35
+ width: 100,
36
+ height: 100,
37
+ }) as PenNode;
38
+
39
+ const makeDoc = (): PenDocument =>
40
+ ({
41
+ id: 'doc-1',
42
+ name: 'Test Document',
43
+ children: [],
44
+ }) as unknown as PenDocument;
45
+
46
+ describe('renderNodeThumbnail — output shape / fallback contract', () => {
47
+ it('returns null in test/Node.js environment (no CanvasKit)', async () => {
48
+ const result = await renderNodeThumbnail(makeRect(), {
49
+ document: makeDoc(),
50
+ pageId: null,
51
+ size: 128,
52
+ });
53
+ // In test env, CanvasKit is not available → null
54
+ expect(result).toBeNull();
55
+ });
56
+
57
+ it('returns null for a null node', async () => {
58
+ const result = await renderNodeThumbnail(null as unknown as PenNode, {
59
+ document: makeDoc(),
60
+ pageId: null,
61
+ });
62
+ expect(result).toBeNull();
63
+ });
64
+
65
+ it('returns null for an invalid size (zero)', async () => {
66
+ const result = await renderNodeThumbnail(makeRect(), {
67
+ document: makeDoc(),
68
+ pageId: null,
69
+ size: 0,
70
+ });
71
+ expect(result).toBeNull();
72
+ });
73
+
74
+ it('returns null for an invalid size (negative)', async () => {
75
+ const result = await renderNodeThumbnail(makeRect(), {
76
+ document: makeDoc(),
77
+ pageId: null,
78
+ size: -10,
79
+ });
80
+ expect(result).toBeNull();
81
+ });
82
+
83
+ it('returns null for a NaN size', async () => {
84
+ const result = await renderNodeThumbnail(makeRect(), {
85
+ document: makeDoc(),
86
+ pageId: null,
87
+ size: NaN,
88
+ });
89
+ expect(result).toBeNull();
90
+ });
91
+
92
+ it('accepts pageId: null without throwing', async () => {
93
+ const result = await renderNodeThumbnail(makeRect(), {
94
+ document: makeDoc(),
95
+ pageId: null,
96
+ });
97
+ // In test env → null, but it must not throw.
98
+ expect(result).toBeNull();
99
+ });
100
+
101
+ it('uses default size of 128 when size is omitted', async () => {
102
+ // Just verify it does not throw — output is null in test env.
103
+ const result = await renderNodeThumbnail(makeRect(), {
104
+ document: makeDoc(),
105
+ pageId: null,
106
+ });
107
+ expect(result).toBeNull();
108
+ });
109
+
110
+ it('handles a ref-type node gracefully (ref resolution may return empty)', async () => {
111
+ const refNode = {
112
+ id: 'ref-1',
113
+ type: 'ref',
114
+ ref: 'non-existent-component',
115
+ x: 0,
116
+ y: 0,
117
+ } as unknown as PenNode;
118
+ const result = await renderNodeThumbnail(refNode, {
119
+ document: makeDoc(),
120
+ pageId: null,
121
+ });
122
+ expect(result).toBeNull();
123
+ });
124
+
125
+ it('handles a doc with children without throwing', async () => {
126
+ const doc: PenDocument = {
127
+ id: 'doc-2',
128
+ name: 'Doc With Children',
129
+ children: [makeRect('comp-1')],
130
+ } as unknown as PenDocument;
131
+ const result = await renderNodeThumbnail(makeRect(), {
132
+ document: doc,
133
+ pageId: 'page-1',
134
+ });
135
+ expect(result).toBeNull();
136
+ });
137
+
138
+ it('result when non-null must be a data URL string (structural contract)', async () => {
139
+ // This test documents the expected non-null contract for when CanvasKit IS
140
+ // available. In the test env the mock returns null so we verify the type
141
+ // contract with a type assertion only — no runtime assertion possible here.
142
+ const result = await renderNodeThumbnail(makeRect(), {
143
+ document: makeDoc(),
144
+ pageId: null,
145
+ });
146
+ // In test env always null. In a live env it would be string | null.
147
+ if (result !== null) {
148
+ expect(typeof result).toBe('string');
149
+ expect(result).toMatch(/^data:/);
150
+ } else {
151
+ expect(result).toBeNull();
152
+ }
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Gap 2: $variable resolution
157
+ // ---------------------------------------------------------------------------
158
+
159
+ it('does not throw when node has a $variable fill reference and document has variables', async () => {
160
+ // Node with a $color-primary fill reference.
161
+ const nodeWithVar = {
162
+ id: 'var-node-1',
163
+ type: 'rectangle',
164
+ x: 0,
165
+ y: 0,
166
+ width: 100,
167
+ height: 100,
168
+ fill: [{ type: 'solid', color: '$color-primary' }],
169
+ } as unknown as PenNode;
170
+
171
+ const docWithVars: PenDocument = {
172
+ id: 'doc-vars',
173
+ name: 'Doc With Variables',
174
+ children: [],
175
+ variables: {
176
+ 'color-primary': { value: '#ff0000' },
177
+ },
178
+ } as unknown as PenDocument;
179
+
180
+ // In test env CanvasKit is unavailable so result is null, but the
181
+ // resolveNodeForCanvas code path must execute without throwing.
182
+ const result = await renderNodeThumbnail(nodeWithVar, {
183
+ document: docWithVars,
184
+ pageId: null,
185
+ });
186
+ expect(result).toBeNull();
187
+ });
188
+
189
+ it('does not throw when document has themes and node uses themed variable', async () => {
190
+ const nodeWithVar = {
191
+ id: 'themed-node-1',
192
+ type: 'rectangle',
193
+ x: 0,
194
+ y: 0,
195
+ width: 50,
196
+ height: 50,
197
+ fill: [{ type: 'solid', color: '$bg-color' }],
198
+ } as unknown as PenNode;
199
+
200
+ const docWithThemes: PenDocument = {
201
+ id: 'doc-themed',
202
+ name: 'Themed Doc',
203
+ children: [],
204
+ variables: {
205
+ 'bg-color': {
206
+ value: [
207
+ { theme: { Mode: 'Light' }, value: '#ffffff' },
208
+ { theme: { Mode: 'Dark' }, value: '#000000' },
209
+ ],
210
+ },
211
+ },
212
+ themes: { Mode: ['Light', 'Dark'] },
213
+ } as unknown as PenDocument;
214
+
215
+ const result = await renderNodeThumbnail(nodeWithVar, {
216
+ document: docWithThemes,
217
+ pageId: null,
218
+ });
219
+ // Still null in test env, but must not throw.
220
+ expect(result).toBeNull();
221
+ });
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // I6: Verify resolveNodeForCanvas is called with document variables + theme
225
+ // ---------------------------------------------------------------------------
226
+
227
+ describe('resolveNodeForCanvas invocation (I6)', () => {
228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
229
+ let spy: ReturnType<typeof vi.spyOn<any, any>>;
230
+
231
+ beforeEach(() => {
232
+ spy = vi.spyOn(penCore, 'resolveNodeForCanvas');
233
+ });
234
+
235
+ afterEach(() => {
236
+ spy.mockRestore();
237
+ });
238
+
239
+ it('calls resolveNodeForCanvas with the node, document variables, and active theme', async () => {
240
+ const nodeWithVar = {
241
+ id: 'spy-node-1',
242
+ type: 'rectangle',
243
+ x: 0,
244
+ y: 0,
245
+ width: 100,
246
+ height: 100,
247
+ fill: [{ type: 'solid', color: '$color-primary' }],
248
+ } as unknown as PenNode;
249
+
250
+ const docWithVars: PenDocument = {
251
+ id: 'doc-spy',
252
+ name: 'Doc With Variables',
253
+ children: [],
254
+ variables: {
255
+ 'color-primary': { value: '#ff0000' },
256
+ },
257
+ } as unknown as PenDocument;
258
+
259
+ await renderNodeThumbnail(nodeWithVar, {
260
+ document: docWithVars,
261
+ pageId: null,
262
+ });
263
+
264
+ // resolveNodeForCanvas must have been called — confirms the variable
265
+ // resolution code path executes rather than being silently bypassed.
266
+ expect(spy).toHaveBeenCalled();
267
+ // The first argument should be the node (or ref-resolved equivalent).
268
+ const [calledNode, calledVars] = spy.mock.calls[0] as [PenNode, Record<string, unknown>];
269
+ expect(calledNode).toMatchObject({ id: 'spy-node-1' });
270
+ // Variables must be the document's variables map.
271
+ expect(calledVars).toEqual(docWithVars.variables);
272
+ });
273
+
274
+ it('calls resolveNodeForCanvas with active theme derived from document themes', async () => {
275
+ const node = {
276
+ id: 'spy-node-2',
277
+ type: 'rectangle',
278
+ x: 0,
279
+ y: 0,
280
+ width: 50,
281
+ height: 50,
282
+ fill: [{ type: 'solid', color: '$bg' }],
283
+ } as unknown as PenNode;
284
+
285
+ const docWithThemes: PenDocument = {
286
+ id: 'doc-spy-themed',
287
+ name: 'Themed Doc',
288
+ children: [],
289
+ variables: {
290
+ bg: {
291
+ value: [
292
+ { theme: { Mode: 'Light' }, value: '#fff' },
293
+ { theme: { Mode: 'Dark' }, value: '#000' },
294
+ ],
295
+ },
296
+ },
297
+ themes: { Mode: ['Light', 'Dark'] },
298
+ } as unknown as PenDocument;
299
+
300
+ await renderNodeThumbnail(node, {
301
+ document: docWithThemes,
302
+ pageId: null,
303
+ });
304
+
305
+ expect(spy).toHaveBeenCalled();
306
+ // Third argument is the active theme object derived from getDefaultTheme.
307
+ const [, , calledTheme] = spy.mock.calls[0] as [PenNode, unknown, Record<string, string>];
308
+ // getDefaultTheme({'Mode': ['Light','Dark']}) returns { Mode: 'Light' }
309
+ expect(calledTheme).toMatchObject({ Mode: 'Light' });
310
+ });
311
+ });
312
+ });