@gjsify/canvas2d 0.1.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,828 @@
1
+ // Canvas 2D context tests for GJS
2
+ // Ported from refs/node-canvas/test/ and refs/headless-gl/test/
3
+ // Original: BSD-2-Clause (headless-gl), MIT (node-canvas)
4
+ // Tests use @gjsify/unit and verify pixel output via getImageData.
5
+
6
+ import { describe, it, expect } from '@gjsify/unit';
7
+ import { HTMLCanvasElement } from '@gjsify/dom-elements';
8
+
9
+ // Import canvas2d to register the '2d' context factory
10
+ import '@gjsify/canvas2d';
11
+ import { CanvasRenderingContext2D, CanvasGradient, Path2D, ImageData, parseColor } from '@gjsify/canvas2d';
12
+
13
+ /** Helper: create a canvas with a 2D context. */
14
+ function createCanvas(width = 100, height = 100) {
15
+ const canvas = new HTMLCanvasElement();
16
+ canvas.width = width;
17
+ canvas.height = height;
18
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
19
+ return { canvas, ctx };
20
+ }
21
+
22
+ /** Helper: get pixel RGBA at (x, y). */
23
+ function getPixel(ctx: CanvasRenderingContext2D, x: number, y: number): [number, number, number, number] {
24
+ const data = ctx.getImageData(x, y, 1, 1);
25
+ return [data.data[0], data.data[1], data.data[2], data.data[3]];
26
+ }
27
+
28
+ export default async () => {
29
+
30
+ // ---- Color parser ----
31
+
32
+ await describe('parseColor', async () => {
33
+ await it('should parse hex colors', async () => {
34
+ const c = parseColor('#ff0000')!;
35
+ expect(c.r).toBe(1);
36
+ expect(c.g).toBe(0);
37
+ expect(c.b).toBe(0);
38
+ expect(c.a).toBe(1);
39
+ });
40
+
41
+ await it('should parse short hex', async () => {
42
+ const c = parseColor('#f00')!;
43
+ expect(c.r).toBe(1);
44
+ expect(c.g).toBe(0);
45
+ expect(c.b).toBe(0);
46
+ });
47
+
48
+ await it('should parse hex with alpha', async () => {
49
+ const c = parseColor('#ff000080')!;
50
+ expect(c.r).toBe(1);
51
+ expect(c.a).toBeGreaterThan(0.49);
52
+ expect(c.a).toBeLessThan(0.51);
53
+ });
54
+
55
+ await it('should parse rgb()', async () => {
56
+ const c = parseColor('rgb(0, 128, 255)')!;
57
+ expect(c.r).toBe(0);
58
+ expect(c.g).toBeGreaterThan(0.49);
59
+ expect(c.g).toBeLessThan(0.51);
60
+ expect(c.b).toBe(1);
61
+ });
62
+
63
+ await it('should parse rgba()', async () => {
64
+ const c = parseColor('rgba(255, 0, 0, 0.5)')!;
65
+ expect(c.r).toBe(1);
66
+ expect(c.a).toBe(0.5);
67
+ });
68
+
69
+ await it('should parse named colors', async () => {
70
+ const c = parseColor('red')!;
71
+ expect(c.r).toBe(1);
72
+ expect(c.g).toBe(0);
73
+ expect(c.b).toBe(0);
74
+ });
75
+
76
+ await it('should parse transparent', async () => {
77
+ const c = parseColor('transparent')!;
78
+ expect(c.a).toBe(0);
79
+ });
80
+
81
+ await it('should return null for invalid colors', async () => {
82
+ expect(parseColor('notacolor')).toBeNull();
83
+ expect(parseColor('')).toBeNull();
84
+ });
85
+ });
86
+
87
+ // ---- Context creation ----
88
+
89
+ await describe('Context creation', async () => {
90
+ await it('should return a CanvasRenderingContext2D for "2d"', async () => {
91
+ const { ctx } = createCanvas();
92
+ expect(ctx).not.toBeNull();
93
+ expect(ctx).toBeDefined();
94
+ });
95
+
96
+ await it('should return the same context on repeated calls', async () => {
97
+ const canvas = new HTMLCanvasElement();
98
+ canvas.width = 50;
99
+ canvas.height = 50;
100
+ const ctx1 = canvas.getContext('2d');
101
+ const ctx2 = canvas.getContext('2d');
102
+ expect(ctx1).toBe(ctx2);
103
+ });
104
+
105
+ await it('should have canvas reference', async () => {
106
+ const { canvas, ctx } = createCanvas();
107
+ expect(ctx.canvas).toBe(canvas);
108
+ });
109
+ });
110
+
111
+ // ---- ImageData ----
112
+
113
+ await describe('ImageData', async () => {
114
+ await it('should create with width and height', async () => {
115
+ const img = new ImageData(10, 20);
116
+ expect(img.width).toBe(10);
117
+ expect(img.height).toBe(20);
118
+ expect(img.data.length).toBe(10 * 20 * 4);
119
+ });
120
+
121
+ await it('should create from existing data', async () => {
122
+ const data = new Uint8ClampedArray(4 * 2 * 2);
123
+ data[0] = 255; // first pixel R
124
+ const img = new ImageData(data, 2, 2);
125
+ expect(img.width).toBe(2);
126
+ expect(img.height).toBe(2);
127
+ expect(img.data[0]).toBe(255);
128
+ });
129
+
130
+ await it('should throw on mismatched data length', async () => {
131
+ const data = new Uint8ClampedArray(10); // not a multiple of 4*width
132
+ let threw = false;
133
+ try {
134
+ new ImageData(data, 3, 1);
135
+ } catch {
136
+ threw = true;
137
+ }
138
+ expect(threw).toBe(true);
139
+ });
140
+ });
141
+
142
+ // ---- fillRect + getImageData pixel verification ----
143
+
144
+ await describe('fillRect', async () => {
145
+ await it('should fill a rectangle with solid color', async () => {
146
+ const { ctx } = createCanvas(10, 10);
147
+ ctx.fillStyle = '#ff0000';
148
+ ctx.fillRect(0, 0, 10, 10);
149
+ const [r, g, b, a] = getPixel(ctx, 5, 5);
150
+ expect(r).toBe(255);
151
+ expect(g).toBe(0);
152
+ expect(b).toBe(0);
153
+ expect(a).toBe(255);
154
+ });
155
+
156
+ await it('should fill partial rectangle', async () => {
157
+ const { ctx } = createCanvas(20, 20);
158
+ ctx.fillStyle = '#00ff00';
159
+ ctx.fillRect(5, 5, 10, 10);
160
+ // Inside the rect
161
+ const [r1, g1, b1, a1] = getPixel(ctx, 10, 10);
162
+ expect(g1).toBe(255);
163
+ expect(a1).toBe(255);
164
+ // Outside the rect (should be transparent)
165
+ const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
166
+ expect(a2).toBe(0);
167
+ });
168
+
169
+ await it('should fill with rgba color', async () => {
170
+ const { ctx } = createCanvas(10, 10);
171
+ ctx.fillStyle = 'rgba(0, 0, 255, 0.5)';
172
+ ctx.fillRect(0, 0, 10, 10);
173
+ const [r, g, b, a] = getPixel(ctx, 5, 5);
174
+ expect(r).toBe(0);
175
+ expect(g).toBe(0);
176
+ // Alpha should be approximately 128 (0.5 * 255)
177
+ expect(a).toBeGreaterThan(120);
178
+ expect(a).toBeLessThan(140);
179
+ });
180
+
181
+ await it('should not affect the current path', async () => {
182
+ // Regression: fillRect added a rectangle to the existing path.
183
+ // Since fill() uses fillPreserve(), the preserved path was
184
+ // repainted with fillRect's color.
185
+ const { ctx } = createCanvas(40, 40);
186
+ // Draw a red circle
187
+ ctx.beginPath();
188
+ ctx.arc(10, 10, 8, 0, Math.PI * 2);
189
+ ctx.fillStyle = '#ff0000';
190
+ ctx.fill();
191
+ // fillRect with green in a different area — must not repaint the circle
192
+ ctx.fillStyle = '#00ff00';
193
+ ctx.fillRect(25, 25, 10, 10);
194
+ // Circle should still be red, not green
195
+ const [r, g, _b, a] = getPixel(ctx, 10, 10);
196
+ expect(r).toBe(255);
197
+ expect(g).toBe(0);
198
+ expect(a).toBe(255);
199
+ // Rectangle should be green
200
+ const [r2, g2, _b2, a2] = getPixel(ctx, 30, 30);
201
+ expect(r2).toBe(0);
202
+ expect(g2).toBe(255);
203
+ expect(a2).toBe(255);
204
+ });
205
+ });
206
+
207
+ // ---- clearRect ----
208
+
209
+ await describe('clearRect', async () => {
210
+ await it('should clear a region to transparent', async () => {
211
+ const { ctx } = createCanvas(20, 20);
212
+ ctx.fillStyle = '#ff0000';
213
+ ctx.fillRect(0, 0, 20, 20);
214
+ ctx.clearRect(5, 5, 10, 10);
215
+ // Inside cleared region
216
+ const [_r, _g, _b, a] = getPixel(ctx, 10, 10);
217
+ expect(a).toBe(0);
218
+ // Outside cleared region (still red)
219
+ const [r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
220
+ expect(r2).toBe(255);
221
+ expect(a2).toBe(255);
222
+ });
223
+
224
+ await it('should not affect the current path', async () => {
225
+ // Regression: clearRect added a rectangle to the current path.
226
+ // Per spec, clearRect must not affect the current path.
227
+ const { ctx } = createCanvas(40, 40);
228
+ ctx.fillStyle = '#ff0000';
229
+ ctx.beginPath();
230
+ ctx.arc(20, 20, 10, 0, Math.PI * 2);
231
+ ctx.fill();
232
+ // clearRect a different region — path should survive
233
+ ctx.clearRect(0, 0, 5, 5);
234
+ // Fill again with the preserved path — should still be a circle
235
+ ctx.fillStyle = '#0000ff';
236
+ ctx.fill();
237
+ // Center of circle should now be blue
238
+ const [r, _g, b, a] = getPixel(ctx, 20, 20);
239
+ expect(r).toBe(0);
240
+ expect(b).toBe(255);
241
+ expect(a).toBe(255);
242
+ });
243
+ });
244
+
245
+ // ---- strokeRect ----
246
+
247
+ await describe('strokeRect', async () => {
248
+ await it('should stroke a rectangle outline', async () => {
249
+ const { ctx } = createCanvas(20, 20);
250
+ ctx.strokeStyle = '#0000ff';
251
+ ctx.lineWidth = 2;
252
+ ctx.strokeRect(2, 2, 16, 16);
253
+ // On the stroke border (top edge around y=2)
254
+ const [_r, _g, b, a] = getPixel(ctx, 10, 2);
255
+ expect(b).toBe(255);
256
+ expect(a).toBe(255);
257
+ // Center should be empty
258
+ const [_r2, _g2, _b2, a2] = getPixel(ctx, 10, 10);
259
+ expect(a2).toBe(0);
260
+ });
261
+ });
262
+
263
+ // ---- Path operations ----
264
+
265
+ await describe('Path operations', async () => {
266
+ await it('should fill a triangle', async () => {
267
+ const { ctx } = createCanvas(20, 20);
268
+ ctx.fillStyle = '#00ff00';
269
+ ctx.beginPath();
270
+ ctx.moveTo(10, 0);
271
+ ctx.lineTo(20, 20);
272
+ ctx.lineTo(0, 20);
273
+ ctx.closePath();
274
+ ctx.fill();
275
+ // Bottom center should be filled
276
+ const [_r, g, _b, a] = getPixel(ctx, 10, 15);
277
+ expect(g).toBe(255);
278
+ expect(a).toBe(255);
279
+ });
280
+
281
+ await it('should fill a circle (arc)', async () => {
282
+ const { ctx } = createCanvas(20, 20);
283
+ ctx.fillStyle = '#ff0000';
284
+ ctx.beginPath();
285
+ ctx.arc(10, 10, 8, 0, Math.PI * 2);
286
+ ctx.fill();
287
+ // Center should be filled
288
+ const [r, _g, _b, a] = getPixel(ctx, 10, 10);
289
+ expect(r).toBe(255);
290
+ expect(a).toBe(255);
291
+ // Corner should be empty
292
+ const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
293
+ expect(a2).toBe(0);
294
+ });
295
+
296
+ await it('should fill a full circle with counterclockwise=true', async () => {
297
+ // Regression: Cairo arcNegative(x,y,r,0,2π) normalizes endAngle to
298
+ // startAngle, producing a zero-length arc. Browsers draw a full circle.
299
+ const { ctx } = createCanvas(20, 20);
300
+ ctx.fillStyle = '#ff0000';
301
+ ctx.beginPath();
302
+ ctx.arc(10, 10, 8, 0, Math.PI * 2, true);
303
+ ctx.fill();
304
+ const [r, _g, _b, a] = getPixel(ctx, 10, 10);
305
+ expect(r).toBe(255);
306
+ expect(a).toBe(255);
307
+ });
308
+
309
+ await it('should fill a rectangle path', async () => {
310
+ const { ctx } = createCanvas(20, 20);
311
+ ctx.fillStyle = '#0000ff';
312
+ ctx.beginPath();
313
+ ctx.rect(2, 2, 16, 16);
314
+ ctx.fill();
315
+ const [_r, _g, b, a] = getPixel(ctx, 10, 10);
316
+ expect(b).toBe(255);
317
+ expect(a).toBe(255);
318
+ });
319
+ });
320
+
321
+ // ---- Transforms ----
322
+
323
+ await describe('Transforms', async () => {
324
+ await it('should translate', async () => {
325
+ const { ctx } = createCanvas(20, 20);
326
+ ctx.fillStyle = '#ff0000';
327
+ ctx.translate(5, 5);
328
+ ctx.fillRect(0, 0, 5, 5);
329
+ // The rect is drawn at (5,5) in device space
330
+ const [r, _g, _b, a] = getPixel(ctx, 7, 7);
331
+ expect(r).toBe(255);
332
+ expect(a).toBe(255);
333
+ // Origin should be empty
334
+ const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
335
+ expect(a2).toBe(0);
336
+ });
337
+
338
+ await it('should scale', async () => {
339
+ const { ctx } = createCanvas(20, 20);
340
+ ctx.fillStyle = '#00ff00';
341
+ ctx.scale(2, 2);
342
+ ctx.fillRect(0, 0, 5, 5);
343
+ // The rect is 10x10 in device space
344
+ const [_r, g, _b, a] = getPixel(ctx, 9, 9);
345
+ expect(g).toBe(255);
346
+ expect(a).toBe(255);
347
+ });
348
+
349
+ await it('should resetTransform', async () => {
350
+ const { ctx } = createCanvas(20, 20);
351
+ ctx.translate(100, 100);
352
+ ctx.resetTransform();
353
+ ctx.fillStyle = '#ff0000';
354
+ ctx.fillRect(0, 0, 5, 5);
355
+ const [r, _g, _b, a] = getPixel(ctx, 2, 2);
356
+ expect(r).toBe(255);
357
+ expect(a).toBe(255);
358
+ });
359
+
360
+ await it('should getTransform return identity by default', async () => {
361
+ const { ctx } = createCanvas();
362
+ const m = ctx.getTransform();
363
+ expect(m.a).toBe(1);
364
+ expect(m.b).toBe(0);
365
+ expect(m.c).toBe(0);
366
+ expect(m.d).toBe(1);
367
+ expect(m.e).toBe(0);
368
+ expect(m.f).toBe(0);
369
+ });
370
+ });
371
+
372
+ // ---- save/restore ----
373
+
374
+ await describe('save/restore', async () => {
375
+ await it('should save and restore fillStyle', async () => {
376
+ const { ctx } = createCanvas();
377
+ ctx.fillStyle = '#ff0000';
378
+ ctx.save();
379
+ ctx.fillStyle = '#00ff00';
380
+ expect(ctx.fillStyle).toBe('#00ff00');
381
+ ctx.restore();
382
+ expect(ctx.fillStyle).toBe('#ff0000');
383
+ });
384
+
385
+ await it('should save and restore lineWidth', async () => {
386
+ const { ctx } = createCanvas();
387
+ ctx.lineWidth = 5;
388
+ ctx.save();
389
+ ctx.lineWidth = 10;
390
+ ctx.restore();
391
+ expect(ctx.lineWidth).toBe(5);
392
+ });
393
+
394
+ await it('should save and restore globalAlpha', async () => {
395
+ const { ctx } = createCanvas();
396
+ ctx.globalAlpha = 0.5;
397
+ ctx.save();
398
+ ctx.globalAlpha = 1.0;
399
+ ctx.restore();
400
+ expect(ctx.globalAlpha).toBe(0.5);
401
+ });
402
+
403
+ await it('should save and restore transforms', async () => {
404
+ const { ctx } = createCanvas(20, 20);
405
+ ctx.translate(5, 5);
406
+ ctx.save();
407
+ ctx.translate(100, 100);
408
+ ctx.restore();
409
+ // After restore, translate(5,5) should still be in effect
410
+ ctx.fillStyle = '#ff0000';
411
+ ctx.fillRect(0, 0, 5, 5);
412
+ const [r, _g, _b, a] = getPixel(ctx, 7, 7);
413
+ expect(r).toBe(255);
414
+ expect(a).toBe(255);
415
+ });
416
+ });
417
+
418
+ // ---- Line properties ----
419
+
420
+ await describe('Line properties', async () => {
421
+ await it('should set and get lineWidth', async () => {
422
+ const { ctx } = createCanvas();
423
+ ctx.lineWidth = 5;
424
+ expect(ctx.lineWidth).toBe(5);
425
+ });
426
+
427
+ await it('should ignore invalid lineWidth', async () => {
428
+ const { ctx } = createCanvas();
429
+ ctx.lineWidth = 5;
430
+ ctx.lineWidth = -1;
431
+ expect(ctx.lineWidth).toBe(5);
432
+ ctx.lineWidth = 0;
433
+ expect(ctx.lineWidth).toBe(5);
434
+ ctx.lineWidth = Infinity;
435
+ expect(ctx.lineWidth).toBe(5);
436
+ });
437
+
438
+ await it('should set and get lineCap', async () => {
439
+ const { ctx } = createCanvas();
440
+ ctx.lineCap = 'round';
441
+ expect(ctx.lineCap).toBe('round');
442
+ ctx.lineCap = 'square';
443
+ expect(ctx.lineCap).toBe('square');
444
+ ctx.lineCap = 'butt';
445
+ expect(ctx.lineCap).toBe('butt');
446
+ });
447
+
448
+ await it('should set and get lineJoin', async () => {
449
+ const { ctx } = createCanvas();
450
+ ctx.lineJoin = 'round';
451
+ expect(ctx.lineJoin).toBe('round');
452
+ ctx.lineJoin = 'bevel';
453
+ expect(ctx.lineJoin).toBe('bevel');
454
+ });
455
+
456
+ await it('should set and get lineDash', async () => {
457
+ const { ctx } = createCanvas();
458
+ ctx.setLineDash([5, 10]);
459
+ expect(ctx.getLineDash().length).toBe(2);
460
+ expect(ctx.getLineDash()[0]).toBe(5);
461
+ expect(ctx.getLineDash()[1]).toBe(10);
462
+ });
463
+
464
+ await it('should ignore negative lineDash values', async () => {
465
+ const { ctx } = createCanvas();
466
+ ctx.setLineDash([5, 10]);
467
+ ctx.setLineDash([-1, 5]);
468
+ // Should not have changed
469
+ expect(ctx.getLineDash()[0]).toBe(5);
470
+ });
471
+ });
472
+
473
+ // ---- globalAlpha ----
474
+
475
+ await describe('globalAlpha', async () => {
476
+ await it('should affect fill opacity', async () => {
477
+ const { ctx } = createCanvas(10, 10);
478
+ ctx.globalAlpha = 0.5;
479
+ ctx.fillStyle = '#ff0000';
480
+ ctx.fillRect(0, 0, 10, 10);
481
+ const [r, _g, _b, a] = getPixel(ctx, 5, 5);
482
+ // Alpha should be approximately 128
483
+ expect(a).toBeGreaterThan(120);
484
+ expect(a).toBeLessThan(140);
485
+ });
486
+
487
+ await it('should reject invalid values', async () => {
488
+ const { ctx } = createCanvas();
489
+ ctx.globalAlpha = 0.5;
490
+ ctx.globalAlpha = -0.1;
491
+ expect(ctx.globalAlpha).toBe(0.5);
492
+ ctx.globalAlpha = 1.1;
493
+ expect(ctx.globalAlpha).toBe(0.5);
494
+ });
495
+ });
496
+
497
+ // ---- globalCompositeOperation ----
498
+
499
+ await describe('globalCompositeOperation', async () => {
500
+ await it('should default to source-over', async () => {
501
+ const { ctx } = createCanvas();
502
+ expect(ctx.globalCompositeOperation).toBe('source-over');
503
+ });
504
+
505
+ await it('should accept valid operations', async () => {
506
+ const { ctx } = createCanvas();
507
+ const ops = ['source-over', 'source-in', 'source-out', 'source-atop',
508
+ 'destination-over', 'destination-in', 'destination-out', 'destination-atop',
509
+ 'lighter', 'copy', 'xor', 'multiply', 'screen', 'overlay',
510
+ 'darken', 'lighten', 'color-dodge', 'color-burn',
511
+ 'hard-light', 'soft-light', 'difference', 'exclusion',
512
+ 'hue', 'saturation', 'color', 'luminosity'];
513
+ for (const op of ops) {
514
+ ctx.globalCompositeOperation = op as GlobalCompositeOperation;
515
+ expect(ctx.globalCompositeOperation).toBe(op);
516
+ }
517
+ });
518
+
519
+ await it('copy should replace destination', async () => {
520
+ const { ctx } = createCanvas(10, 10);
521
+ ctx.fillStyle = '#ff0000';
522
+ ctx.fillRect(0, 0, 10, 10);
523
+ ctx.globalCompositeOperation = 'copy';
524
+ ctx.fillStyle = 'rgba(0, 255, 0, 0.5)';
525
+ ctx.fillRect(0, 0, 10, 10);
526
+ const [r, g, _b, a] = getPixel(ctx, 5, 5);
527
+ // Red should be gone (copy replaces)
528
+ expect(r).toBe(0);
529
+ expect(g).toBeGreaterThan(0);
530
+ expect(a).toBeGreaterThan(120);
531
+ expect(a).toBeLessThan(140);
532
+ });
533
+ });
534
+
535
+ // ---- Gradients ----
536
+
537
+ await describe('Gradients', async () => {
538
+ await it('should create a linear gradient', async () => {
539
+ const { ctx } = createCanvas(20, 10);
540
+ const grad = ctx.createLinearGradient(0, 0, 20, 0);
541
+ grad.addColorStop(0, '#ff0000');
542
+ grad.addColorStop(1, '#0000ff');
543
+ ctx.fillStyle = grad;
544
+ ctx.fillRect(0, 0, 20, 10);
545
+ // Left should be red-ish
546
+ const [r1, _g1, b1] = getPixel(ctx, 1, 5);
547
+ expect(r1).toBeGreaterThan(200);
548
+ expect(b1).toBeLessThan(50);
549
+ // Right should be blue-ish
550
+ const [r2, _g2, b2] = getPixel(ctx, 18, 5);
551
+ expect(r2).toBeLessThan(50);
552
+ expect(b2).toBeGreaterThan(200);
553
+ });
554
+
555
+ await it('should create a radial gradient', async () => {
556
+ const { ctx } = createCanvas(20, 20);
557
+ const grad = ctx.createRadialGradient(10, 10, 0, 10, 10, 10);
558
+ grad.addColorStop(0, '#ffffff');
559
+ grad.addColorStop(1, '#000000');
560
+ ctx.fillStyle = grad;
561
+ ctx.fillRect(0, 0, 20, 20);
562
+ // Center should be bright
563
+ const [r1] = getPixel(ctx, 10, 10);
564
+ expect(r1).toBeGreaterThan(200);
565
+ // Edge should be dark
566
+ const [r2] = getPixel(ctx, 19, 10);
567
+ expect(r2).toBeLessThan(50);
568
+ });
569
+ });
570
+
571
+ // ---- getImageData / putImageData round-trip ----
572
+
573
+ await describe('getImageData / putImageData', async () => {
574
+ await it('should round-trip pixel data', async () => {
575
+ const { ctx } = createCanvas(10, 10);
576
+ ctx.fillStyle = '#ff8000';
577
+ ctx.fillRect(0, 0, 10, 10);
578
+ const imageData = ctx.getImageData(0, 0, 10, 10);
579
+ expect(imageData.width).toBe(10);
580
+ expect(imageData.height).toBe(10);
581
+ // Check orange pixel
582
+ expect(imageData.data[0]).toBe(255); // R
583
+ expect(imageData.data[1]).toBeGreaterThan(120); // G (~128)
584
+ expect(imageData.data[2]).toBe(0); // B
585
+ expect(imageData.data[3]).toBe(255); // A
586
+
587
+ // Clear and put it back
588
+ ctx.clearRect(0, 0, 10, 10);
589
+ ctx.putImageData(imageData, 0, 0);
590
+ const [r, _g, _b, a] = getPixel(ctx, 5, 5);
591
+ expect(r).toBe(255);
592
+ expect(a).toBe(255);
593
+ });
594
+
595
+ await it('should createImageData with correct dimensions', async () => {
596
+ const { ctx } = createCanvas();
597
+ const img = ctx.createImageData(5, 3);
598
+ expect(img.width).toBe(5);
599
+ expect(img.height).toBe(3);
600
+ expect(img.data.length).toBe(5 * 3 * 4);
601
+ // Should be all zeros (transparent black)
602
+ expect(img.data[0]).toBe(0);
603
+ });
604
+ });
605
+
606
+ // ---- Path2D ----
607
+
608
+ await describe('Path2D', async () => {
609
+ await it('should create and fill a Path2D rectangle', async () => {
610
+ const { ctx } = createCanvas(20, 20);
611
+ const path = new Path2D();
612
+ path.rect(2, 2, 16, 16);
613
+ ctx.fillStyle = '#ff0000';
614
+ ctx.fill(path);
615
+ const [r, _g, _b, a] = getPixel(ctx, 10, 10);
616
+ expect(r).toBe(255);
617
+ expect(a).toBe(255);
618
+ });
619
+
620
+ await it('should stroke a Path2D', async () => {
621
+ const { ctx } = createCanvas(20, 20);
622
+ const path = new Path2D();
623
+ path.moveTo(0, 10);
624
+ path.lineTo(20, 10);
625
+ ctx.strokeStyle = '#00ff00';
626
+ ctx.lineWidth = 2;
627
+ ctx.stroke(path);
628
+ const [_r, g, _b, a] = getPixel(ctx, 10, 10);
629
+ expect(g).toBe(255);
630
+ expect(a).toBe(255);
631
+ });
632
+
633
+ await it('should copy a Path2D', async () => {
634
+ const path1 = new Path2D();
635
+ path1.rect(0, 0, 10, 10);
636
+ const path2 = new Path2D(path1);
637
+ // path2 should have the same ops
638
+ expect(path2._ops.length).toBe(path1._ops.length);
639
+ });
640
+
641
+ await it('should addPath', async () => {
642
+ const path1 = new Path2D();
643
+ path1.rect(0, 0, 5, 5);
644
+ const path2 = new Path2D();
645
+ path2.rect(10, 10, 5, 5);
646
+ path1.addPath(path2);
647
+ expect(path1._ops.length).toBe(2);
648
+ });
649
+ });
650
+
651
+ // ---- Clipping ----
652
+
653
+ await describe('Clipping', async () => {
654
+ await it('should clip to a rectangular region', async () => {
655
+ const { ctx } = createCanvas(20, 20);
656
+ ctx.beginPath();
657
+ ctx.rect(5, 5, 10, 10);
658
+ ctx.clip();
659
+ ctx.fillStyle = '#ff0000';
660
+ ctx.fillRect(0, 0, 20, 20);
661
+ // Inside clip → filled
662
+ const [r, _g, _b, a] = getPixel(ctx, 10, 10);
663
+ expect(r).toBe(255);
664
+ expect(a).toBe(255);
665
+ // Outside clip → transparent
666
+ const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
667
+ expect(a2).toBe(0);
668
+ });
669
+ });
670
+
671
+ // ---- Text ----
672
+
673
+ await describe('Text', async () => {
674
+ await it('should measureText return non-zero width', async () => {
675
+ const { ctx } = createCanvas();
676
+ ctx.font = '16px sans-serif';
677
+ const m = ctx.measureText('Hello World');
678
+ expect(m.width).toBeGreaterThan(0);
679
+ });
680
+
681
+ await it('should measureText empty string return 0 width', async () => {
682
+ const { ctx } = createCanvas();
683
+ const m = ctx.measureText('');
684
+ expect(m.width).toBe(0);
685
+ });
686
+
687
+ await it('should fillText produce visible pixels', async () => {
688
+ const { ctx } = createCanvas(100, 30);
689
+ ctx.fillStyle = '#000000';
690
+ ctx.font = '20px sans-serif';
691
+ ctx.fillText('X', 10, 20);
692
+ // Check around the area where the text should be
693
+ const imageData = ctx.getImageData(0, 0, 100, 30);
694
+ let nonZeroPixels = 0;
695
+ for (let i = 3; i < imageData.data.length; i += 4) {
696
+ if (imageData.data[i] > 0) nonZeroPixels++;
697
+ }
698
+ expect(nonZeroPixels).toBeGreaterThan(0);
699
+ });
700
+
701
+ await it('should set and get font', async () => {
702
+ const { ctx } = createCanvas();
703
+ ctx.font = 'bold 20px Arial';
704
+ expect(ctx.font).toBe('bold 20px Arial');
705
+ });
706
+
707
+ await it('should set and get textAlign', async () => {
708
+ const { ctx } = createCanvas();
709
+ ctx.textAlign = 'center';
710
+ expect(ctx.textAlign).toBe('center');
711
+ ctx.textAlign = 'right';
712
+ expect(ctx.textAlign).toBe('right');
713
+ });
714
+
715
+ await it('should set and get textBaseline', async () => {
716
+ const { ctx } = createCanvas();
717
+ ctx.textBaseline = 'top';
718
+ expect(ctx.textBaseline).toBe('top');
719
+ ctx.textBaseline = 'middle';
720
+ expect(ctx.textBaseline).toBe('middle');
721
+ });
722
+ });
723
+
724
+ // ---- Hit testing ----
725
+
726
+ await describe('Hit testing', async () => {
727
+ await it('should detect point in rectangular path', async () => {
728
+ const { ctx } = createCanvas(20, 20);
729
+ ctx.beginPath();
730
+ ctx.rect(5, 5, 10, 10);
731
+ expect(ctx.isPointInPath(10, 10)).toBe(true);
732
+ expect(ctx.isPointInPath(0, 0)).toBe(false);
733
+ });
734
+
735
+ await it('should detect point in stroke', async () => {
736
+ const { ctx } = createCanvas(20, 20);
737
+ ctx.lineWidth = 4;
738
+ ctx.beginPath();
739
+ ctx.moveTo(0, 10);
740
+ ctx.lineTo(20, 10);
741
+ expect(ctx.isPointInStroke(10, 10)).toBe(true);
742
+ expect(ctx.isPointInStroke(10, 0)).toBe(false);
743
+ });
744
+
745
+ await it('should detect point in Path2D', async () => {
746
+ const { ctx } = createCanvas(20, 20);
747
+ const path = new Path2D();
748
+ path.rect(5, 5, 10, 10);
749
+ expect(ctx.isPointInPath(path, 10, 10)).toBe(true);
750
+ expect(ctx.isPointInPath(path, 0, 0)).toBe(false);
751
+ });
752
+ });
753
+
754
+ // ---- Shadow properties ----
755
+
756
+ await describe('Shadow properties', async () => {
757
+ await it('should set and get shadow properties', async () => {
758
+ const { ctx } = createCanvas();
759
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
760
+ expect(ctx.shadowColor).toBe('rgba(0, 0, 0, 0.5)');
761
+ ctx.shadowBlur = 10;
762
+ expect(ctx.shadowBlur).toBe(10);
763
+ ctx.shadowOffsetX = 5;
764
+ expect(ctx.shadowOffsetX).toBe(5);
765
+ ctx.shadowOffsetY = 3;
766
+ expect(ctx.shadowOffsetY).toBe(3);
767
+ });
768
+
769
+ await it('should reject negative shadowBlur', async () => {
770
+ const { ctx } = createCanvas();
771
+ ctx.shadowBlur = 5;
772
+ ctx.shadowBlur = -1;
773
+ expect(ctx.shadowBlur).toBe(5);
774
+ });
775
+ });
776
+
777
+ // ---- Ellipse and roundRect ----
778
+
779
+ await describe('Ellipse and roundRect', async () => {
780
+ await it('should fill an ellipse', async () => {
781
+ const { ctx } = createCanvas(30, 20);
782
+ ctx.fillStyle = '#ff0000';
783
+ ctx.beginPath();
784
+ ctx.ellipse(15, 10, 12, 8, 0, 0, Math.PI * 2);
785
+ ctx.fill();
786
+ const [r, _g, _b, a] = getPixel(ctx, 15, 10);
787
+ expect(r).toBe(255);
788
+ expect(a).toBe(255);
789
+ });
790
+
791
+ await it('should throw on negative ellipse radii', async () => {
792
+ const { ctx } = createCanvas();
793
+ let threw = false;
794
+ try {
795
+ ctx.ellipse(10, 10, -5, 5, 0, 0, Math.PI * 2);
796
+ } catch {
797
+ threw = true;
798
+ }
799
+ expect(threw).toBe(true);
800
+ });
801
+
802
+ await it('should fill a roundRect', async () => {
803
+ const { ctx } = createCanvas(30, 30);
804
+ ctx.fillStyle = '#0000ff';
805
+ ctx.beginPath();
806
+ ctx.roundRect(2, 2, 26, 26, 5);
807
+ ctx.fill();
808
+ const [_r, _g, b, a] = getPixel(ctx, 15, 15);
809
+ expect(b).toBe(255);
810
+ expect(a).toBe(255);
811
+ });
812
+ });
813
+
814
+ // ---- imageSmoothingEnabled ----
815
+
816
+ await describe('imageSmoothingEnabled', async () => {
817
+ await it('should default to true', async () => {
818
+ const { ctx } = createCanvas();
819
+ expect(ctx.imageSmoothingEnabled).toBe(true);
820
+ });
821
+
822
+ await it('should be settable', async () => {
823
+ const { ctx } = createCanvas();
824
+ ctx.imageSmoothingEnabled = false;
825
+ expect(ctx.imageSmoothingEnabled).toBe(false);
826
+ });
827
+ });
828
+ };