@zseven-w/pen-renderer 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.
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SkiaFontManager } from '../font-manager';
3
+
4
+ // Minimal mock CanvasKit shim — only the bits SkiaFontManager constructor touches.
5
+ function makeMockCk(): unknown {
6
+ return {
7
+ TypefaceFontProvider: {
8
+ Make: () => ({ registerFont: () => {} }),
9
+ },
10
+ };
11
+ }
12
+
13
+ describe('SkiaFontManager.pendingCount / flushPending', () => {
14
+ it('starts with pendingCount = 0', () => {
15
+ const fm = new SkiaFontManager(makeMockCk() as never);
16
+ expect(fm.pendingCount()).toBe(0);
17
+ });
18
+
19
+ it('flushPending resolves immediately when nothing is pending', async () => {
20
+ const fm = new SkiaFontManager(makeMockCk() as never);
21
+ let resolved = false;
22
+ await fm.flushPending().then(() => {
23
+ resolved = true;
24
+ });
25
+ expect(resolved).toBe(true);
26
+ });
27
+
28
+ it('tracks in-flight promises injected via the pendingFetches map', async () => {
29
+ const fm = new SkiaFontManager(makeMockCk() as never);
30
+ // Use private access via cast — testing internals to verify the new
31
+ // public methods read the map correctly without coupling tests to
32
+ // network/font-loading machinery.
33
+ let releaseA: () => void = () => {};
34
+ let releaseB: () => void = () => {};
35
+ const pA = new Promise<boolean>((resolve) => {
36
+ releaseA = () => resolve(true);
37
+ });
38
+ const pB = new Promise<boolean>((resolve) => {
39
+ releaseB = () => resolve(true);
40
+ });
41
+ (fm as unknown as { pendingFetches: Map<string, Promise<boolean>> }).pendingFetches.set(
42
+ 'a',
43
+ pA,
44
+ );
45
+ (fm as unknown as { pendingFetches: Map<string, Promise<boolean>> }).pendingFetches.set(
46
+ 'b',
47
+ pB,
48
+ );
49
+ expect(fm.pendingCount()).toBe(2);
50
+
51
+ let flushResolved = false;
52
+ const flushed = fm.flushPending().then(() => {
53
+ flushResolved = true;
54
+ });
55
+ await new Promise((r) => setTimeout(r, 10));
56
+ expect(flushResolved).toBe(false);
57
+
58
+ releaseA();
59
+ releaseB();
60
+ await pA;
61
+ await pB;
62
+ await flushed;
63
+ expect(flushResolved).toBe(true);
64
+ });
65
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { SkiaImageLoader } from '../image-loader';
4
+
5
+ function makeMockCk(): unknown {
6
+ return {};
7
+ }
8
+
9
+ const originalDocument = globalThis.document;
10
+ const OriginalImage = globalThis.Image;
11
+
12
+ describe('SkiaImageLoader', () => {
13
+ it('starts with pendingCount = 0', () => {
14
+ const loader = new SkiaImageLoader(makeMockCk() as never);
15
+ expect(loader.pendingCount()).toBe(0);
16
+ });
17
+
18
+ it('flushPending resolves immediately when nothing is pending', async () => {
19
+ const loader = new SkiaImageLoader(makeMockCk() as never);
20
+ let resolved = false;
21
+ await loader.flushPending().then(() => {
22
+ resolved = true;
23
+ });
24
+ expect(resolved).toBe(true);
25
+ });
26
+
27
+ it('tracks in-flight promises injected via the pendingPromises set', async () => {
28
+ const loader = new SkiaImageLoader(makeMockCk() as never);
29
+ let release: () => void = () => {};
30
+ const pending = new Promise<void>((resolve) => {
31
+ release = () => resolve();
32
+ });
33
+ (loader as unknown as { pendingPromises: Set<Promise<unknown>> }).pendingPromises.add(pending);
34
+ expect(loader.pendingCount()).toBe(1);
35
+
36
+ let flushResolved = false;
37
+ const flushed = loader.flushPending().then(() => {
38
+ flushResolved = true;
39
+ });
40
+ await new Promise((r) => setTimeout(r, 10));
41
+ expect(flushResolved).toBe(false);
42
+
43
+ release();
44
+ await pending;
45
+ await flushed;
46
+ expect(flushResolved).toBe(true);
47
+ });
48
+
49
+ it('downscales oversized decoded images before creating a CanvasKit image', async () => {
50
+ let drawSize: { width: number; height: number } | null = null;
51
+ let imageDataSize: { width: number; height: number } | null = null;
52
+ let makeImageSize: { width: number; height: number } | null = null;
53
+
54
+ (globalThis as { document?: Document }).document = {
55
+ createElement(tag: string) {
56
+ expect(tag).toBe('canvas');
57
+
58
+ const canvas = {
59
+ width: 0,
60
+ height: 0,
61
+ getContext() {
62
+ return {
63
+ drawImage(_img: unknown, _x: number, _y: number, width: number, height: number) {
64
+ drawSize = { width, height };
65
+ },
66
+ getImageData(_x: number, _y: number, width: number, height: number) {
67
+ imageDataSize = { width, height };
68
+ return { data: new Uint8ClampedArray(width * height * 4) };
69
+ },
70
+ };
71
+ },
72
+ };
73
+
74
+ return canvas as unknown as HTMLCanvasElement;
75
+ },
76
+ } as Document;
77
+
78
+ class MockImage {
79
+ naturalWidth = 8192;
80
+ naturalHeight = 4096;
81
+ width = 8192;
82
+ height = 4096;
83
+ onload: ((event: Event) => void) | null = null;
84
+ onerror: ((event: string | Event) => void) | null = null;
85
+
86
+ set src(_value: string) {
87
+ queueMicrotask(() => {
88
+ this.onload?.({} as Event);
89
+ });
90
+ }
91
+ }
92
+
93
+ (globalThis as { Image?: typeof Image }).Image = MockImage as unknown as typeof Image;
94
+
95
+ try {
96
+ const ck = {
97
+ AlphaType: { Unpremul: 0 },
98
+ ColorType: { RGBA_8888: 0 },
99
+ ColorSpace: { SRGB: 0 },
100
+ MakeImage(info: { width: number; height: number }) {
101
+ makeImageSize = { width: info.width, height: info.height };
102
+ return {
103
+ delete() {},
104
+ width: () => info.width,
105
+ height: () => info.height,
106
+ };
107
+ },
108
+ };
109
+
110
+ const loader = new SkiaImageLoader(ck as any);
111
+ const loaded = new Promise<void>((resolve) => {
112
+ loader.setOnLoaded(resolve);
113
+ });
114
+
115
+ loader.request('large-image.png');
116
+ await loaded;
117
+
118
+ expect(drawSize).toEqual({ width: 4096, height: 2048 });
119
+ expect(imageDataSize).toEqual({ width: 4096, height: 2048 });
120
+ expect(makeImageSize).toEqual({ width: 4096, height: 2048 });
121
+ expect(loader.getStatus('large-image.png')).toEqual({ state: 'loaded' });
122
+ } finally {
123
+ if (originalDocument === undefined) {
124
+ delete (globalThis as { document?: Document }).document;
125
+ } else {
126
+ (globalThis as { document?: Document }).document = originalDocument;
127
+ }
128
+
129
+ if (OriginalImage === undefined) {
130
+ delete (globalThis as { Image?: typeof Image }).Image;
131
+ } else {
132
+ (globalThis as { Image?: typeof Image }).Image = OriginalImage;
133
+ }
134
+ }
135
+ });
136
+ });
@@ -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
+ });