@flighthq/bitmap 0.5.0-next.1944.cd6b6e7 → 0.5.1-edge.638.d4dbd4e

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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/dist/bitmapDraw.d.ts.map +1 -1
  3. package/dist/bitmapDraw.js +3 -2
  4. package/dist/bitmapDraw.js.map +1 -1
  5. package/dist/bitmapEncode.d.ts +3 -2
  6. package/dist/bitmapEncode.d.ts.map +1 -1
  7. package/dist/bitmapEncode.js +20 -14
  8. package/dist/bitmapEncode.js.map +1 -1
  9. package/dist/bitmapEncodeBackend.d.ts +11 -0
  10. package/dist/bitmapEncodeBackend.d.ts.map +1 -0
  11. package/dist/bitmapEncodeBackend.js +58 -0
  12. package/dist/bitmapEncodeBackend.js.map +1 -0
  13. package/dist/bitmapFrom.d.ts +6 -8
  14. package/dist/bitmapFrom.d.ts.map +1 -1
  15. package/dist/bitmapFrom.js +8 -56
  16. package/dist/bitmapFrom.js.map +1 -1
  17. package/dist/bitmapReadbackBackend.d.ts +7 -0
  18. package/dist/bitmapReadbackBackend.d.ts.map +1 -0
  19. package/dist/bitmapReadbackBackend.js +21 -0
  20. package/dist/bitmapReadbackBackend.js.map +1 -0
  21. package/dist/bitmapReadbackResolver.d.ts +8 -0
  22. package/dist/bitmapReadbackResolver.d.ts.map +1 -0
  23. package/dist/bitmapReadbackResolver.js +12 -0
  24. package/dist/bitmapReadbackResolver.js.map +1 -0
  25. package/dist/contract.d.ts +2 -0
  26. package/dist/contract.d.ts.map +1 -1
  27. package/dist/contract.js +2 -0
  28. package/dist/contract.js.map +1 -1
  29. package/dist/explainBitmapReadback.d.ts.map +1 -1
  30. package/dist/explainBitmapReadback.js +7 -36
  31. package/dist/explainBitmapReadback.js.map +1 -1
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/package.json +4 -4
  37. package/src/bitmapDraw.test.ts +111 -16
  38. package/src/bitmapEncode.test.ts +99 -13
  39. package/src/bitmapEncodeBackend.test.ts +146 -0
  40. package/src/bitmapFrom.test.ts +31 -44
  41. package/src/bitmapReadbackBackend.test.ts +69 -0
  42. package/src/bitmapReadbackResolver.test.ts +32 -0
  43. package/src/explainBitmapReadback.test.ts +74 -54
@@ -12,21 +12,58 @@ function readExtractedPixel(buf: Uint8ClampedArray, regionWidth: number, x: numb
12
12
  return ((buf[i] << 24) | (buf[i + 1] << 16) | (buf[i + 2] << 8) | buf[i + 3]) >>> 0;
13
13
  }
14
14
 
15
- describe('drawBitmap', () => {
16
- it('does not throw when drawing onto a canvas', () => {
17
- const src = createBitmap(2, 2, 0x112233ff);
18
- const canvas = document.createElement('canvas');
19
- canvas.width = 4;
20
- canvas.height = 4;
21
- expect(() => drawBitmap(canvas, region(src), 0, 0)).not.toThrow();
15
+ function guardGlobalImageDataAccess() {
16
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'ImageData');
17
+ let accesses = 0;
18
+ Object.defineProperty(globalThis, 'ImageData', {
19
+ configurable: true,
20
+ get() {
21
+ accesses++;
22
+ throw new Error('global ImageData must not be accessed');
23
+ },
22
24
  });
25
+ return {
26
+ get accesses() {
27
+ return accesses;
28
+ },
29
+ restore() {
30
+ if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'ImageData');
31
+ else Object.defineProperty(globalThis, 'ImageData', descriptor);
32
+ },
33
+ };
34
+ }
23
35
 
24
- it('does not throw when drawing at an offset', () => {
25
- const src = createBitmap(2, 2, 0x112233ff);
36
+ describe('drawBitmap', () => {
37
+ it('allocates through one destination context and puts the exact extracted pixels at the caller coordinates', () => {
38
+ const src = createBitmap(2, 1);
39
+ setBitmapPixel(src, 0, 0, 0x112233ff);
40
+ setBitmapPixel(src, 1, 0, 0xaabbccdd);
41
+ const imageData = {
42
+ colorSpace: 'srgb',
43
+ data: new Uint8ClampedArray(8),
44
+ height: 1,
45
+ width: 2,
46
+ } as ImageData;
47
+ const createImageData = vi.fn(() => imageData);
48
+ const putImageData = vi.fn();
49
+ const getContext = vi.fn(() => ({ createImageData, putImageData }));
26
50
  const canvas = document.createElement('canvas');
27
- canvas.width = 8;
28
- canvas.height = 8;
29
- expect(() => drawBitmap(canvas, region(src), 2, 2)).not.toThrow();
51
+ Object.defineProperty(canvas, 'getContext', { configurable: true, value: getContext });
52
+ const globalImageData = guardGlobalImageDataAccess();
53
+
54
+ try {
55
+ drawBitmap(canvas, region(src), 7, -3);
56
+ expect(getContext).toHaveBeenCalledOnce();
57
+ expect(getContext).toHaveBeenCalledWith('2d');
58
+ expect(createImageData).toHaveBeenCalledOnce();
59
+ expect(createImageData).toHaveBeenCalledWith(2, 1);
60
+ expect(Array.from(imageData.data)).toEqual([17, 34, 51, 255, 170, 187, 204, 221]);
61
+ expect(putImageData).toHaveBeenCalledOnce();
62
+ expect(putImageData).toHaveBeenCalledWith(imageData, 7, -3);
63
+ expect(globalImageData.accesses).toBe(0);
64
+ } finally {
65
+ globalImageData.restore();
66
+ }
30
67
  });
31
68
 
32
69
  it('extracts correct pixels from a filled source', () => {
@@ -58,11 +95,69 @@ describe('drawBitmap', () => {
58
95
  expect(readExtractedPixel(buf, r.width, 1, 1)).toBe(0x00000000);
59
96
  });
60
97
 
61
- it('is a no-op for a zero-dimension region', () => {
98
+ it.each([
99
+ ['width', 0, 1],
100
+ ['height', 1, 0],
101
+ ] as const)('is a no-op for a zero %s before context, allocation, extraction, or put', (_axis, width, height) => {
62
102
  const src = createBitmap(2, 2, 0x112233ff);
103
+ const sourceData = src.data;
104
+ let sourceDataAccesses = 0;
105
+ Object.defineProperty(src, 'data', {
106
+ configurable: true,
107
+ get() {
108
+ sourceDataAccesses++;
109
+ return sourceData;
110
+ },
111
+ });
112
+ const createImageData = vi.fn();
113
+ const putImageData = vi.fn();
114
+ const getContext = vi.fn(() => ({ createImageData, putImageData }));
115
+ const canvas = document.createElement('canvas');
116
+ Object.defineProperty(canvas, 'getContext', { configurable: true, value: getContext });
117
+ const globalImageData = guardGlobalImageDataAccess();
118
+
119
+ try {
120
+ expect(() => drawBitmap(canvas, region(src, 0, 0, width, height), 0, 0)).not.toThrow();
121
+ expect(getContext).not.toHaveBeenCalled();
122
+ expect(createImageData).not.toHaveBeenCalled();
123
+ expect(putImageData).not.toHaveBeenCalled();
124
+ expect(sourceDataAccesses).toBe(0);
125
+ expect(globalImageData.accesses).toBe(0);
126
+ } finally {
127
+ globalImageData.restore();
128
+ }
129
+ });
130
+
131
+ it('throws from a null positive-size context before allocation, extraction, or put', () => {
132
+ const src = createBitmap(1, 1, 0x112233ff);
133
+ const sourceData = src.data;
134
+ let sourceDataAccesses = 0;
135
+ Object.defineProperty(src, 'data', {
136
+ configurable: true,
137
+ get() {
138
+ sourceDataAccesses++;
139
+ return sourceData;
140
+ },
141
+ });
142
+ const getContext = vi.fn(() => null);
63
143
  const canvas = document.createElement('canvas');
64
- canvas.width = 4;
65
- canvas.height = 4;
66
- expect(() => drawBitmap(canvas, region(src, 0, 0, 0, 0), 0, 0)).not.toThrow();
144
+ Object.defineProperty(canvas, 'getContext', { configurable: true, value: getContext });
145
+ const globalImageData = guardGlobalImageDataAccess();
146
+ const createImageData = vi.spyOn(CanvasRenderingContext2D.prototype, 'createImageData');
147
+ const putImageData = vi.spyOn(CanvasRenderingContext2D.prototype, 'putImageData');
148
+
149
+ try {
150
+ expect(() => drawBitmap(canvas, region(src), 0, 0)).toThrow();
151
+ expect(getContext).toHaveBeenCalledOnce();
152
+ expect(getContext).toHaveBeenCalledWith('2d');
153
+ expect(createImageData).not.toHaveBeenCalled();
154
+ expect(putImageData).not.toHaveBeenCalled();
155
+ expect(sourceDataAccesses).toBe(0);
156
+ expect(globalImageData.accesses).toBe(0);
157
+ } finally {
158
+ createImageData.mockRestore();
159
+ putImageData.mockRestore();
160
+ globalImageData.restore();
161
+ }
67
162
  });
68
163
  });
@@ -1,18 +1,104 @@
1
+ import type { BitmapEncodeBackend, ImageFormat } from '@flighthq/types/contract';
2
+
1
3
  import { createBitmap } from './bitmap';
2
- import { encodeBitmap } from './bitmapEncode';
4
+ import { encodeBitmap, explainBitmapEncodeFailure } from './bitmapEncode';
5
+ import {
6
+ installBitmapEncodeHostBackend,
7
+ resetBitmapEncodeBackendForTest,
8
+ setBitmapEncodeBackend,
9
+ } from './bitmapEncodeBackend';
10
+
11
+ function createBackend(
12
+ supportedFormats: BitmapEncodeBackend['supportedFormats'] = ['jpeg', 'png'],
13
+ bytes: Uint8Array = new Uint8Array([9, 8, 7]),
14
+ ) {
15
+ return {
16
+ encodeBitmap: vi.fn(() => bytes),
17
+ supportedFormats,
18
+ } satisfies BitmapEncodeBackend;
19
+ }
20
+
21
+ afterEach(() => {
22
+ resetBitmapEncodeBackendForTest();
23
+ });
3
24
 
4
25
  describe('encodeBitmap', () => {
5
- it('returns a non-empty Uint8Array for a simple image', () => {
6
- const img = createBitmap(2, 2, 0x112233ff);
7
- const bytes = encodeBitmap(img);
8
- expect(bytes).toBeInstanceOf(Uint8Array);
9
- expect(bytes.length).toBeGreaterThan(0);
10
- });
11
-
12
- it('accepts jpeg format', () => {
13
- const img = createBitmap(2, 2, 0x112233ff);
14
- const bytes = encodeBitmap(img, 'jpeg');
15
- expect(bytes).toBeInstanceOf(Uint8Array);
16
- expect(bytes.length).toBeGreaterThan(0);
26
+ it('dispatches the default png format and quality to the selected backend', () => {
27
+ const backend = createBackend();
28
+ const source = createBitmap(2, 2, 0x112233ff);
29
+ setBitmapEncodeBackend(backend);
30
+ expect(encodeBitmap(source)).toEqual(new Uint8Array([9, 8, 7]));
31
+ expect(backend.encodeBitmap).toHaveBeenCalledWith(source, 'png', 0.9);
32
+ });
33
+
34
+ it('forwards jpeg and caller quality unchanged', () => {
35
+ const backend = createBackend();
36
+ const source = createBitmap(1, 1);
37
+ setBitmapEncodeBackend(backend);
38
+ encodeBitmap(source, 'jpeg', 1.25);
39
+ expect(backend.encodeBitmap).toHaveBeenCalledWith(source, 'jpeg', 1.25);
40
+ });
41
+
42
+ it('preserves the legacy runtime fallback from every non-jpeg value to png', () => {
43
+ const backend = createBackend();
44
+ const source = createBitmap(1, 1);
45
+ setBitmapEncodeBackend(backend);
46
+ encodeBitmap(source, 'webp' as ImageFormat);
47
+ expect(backend.encodeBitmap).toHaveBeenCalledWith(source, 'png', 0.9);
48
+ });
49
+
50
+ it('returns null when no backend is installed', () => {
51
+ expect(encodeBitmap(createBitmap(1, 1))).toBeNull();
52
+ });
53
+
54
+ it('returns null without invoking an installed backend that does not support the format', () => {
55
+ const backend = createBackend(['png']);
56
+ setBitmapEncodeBackend(backend);
57
+ expect(encodeBitmap(createBitmap(1, 1), 'jpeg')).toBeNull();
58
+ expect(backend.encodeBitmap).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('keeps a custom backend terminal above an installed host', () => {
62
+ const host = createBackend();
63
+ const custom = createBackend(['png'], new Uint8Array([3]));
64
+ installBitmapEncodeHostBackend(host);
65
+ setBitmapEncodeBackend(custom);
66
+ expect(encodeBitmap(createBitmap(1, 1), 'jpeg')).toBeNull();
67
+ expect(custom.encodeBitmap).not.toHaveBeenCalled();
68
+ expect(host.encodeBitmap).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it('propagates genuine supported-format encoding faults', () => {
72
+ const failure = new Error('encode failed');
73
+ const backend: BitmapEncodeBackend = {
74
+ encodeBitmap(): Uint8Array {
75
+ throw failure;
76
+ },
77
+ supportedFormats: ['png'],
78
+ };
79
+ setBitmapEncodeBackend(backend);
80
+ expect(() => encodeBitmap(createBitmap(1, 1))).toThrow(failure);
81
+ });
82
+ });
83
+
84
+ describe('explainBitmapEncodeFailure', () => {
85
+ it('reports a missing backend for the normalized requested format', () => {
86
+ expect(explainBitmapEncodeFailure('jpeg')).toEqual({ format: 'jpeg', reason: 'backend-not-installed' });
87
+ expect(explainBitmapEncodeFailure('webp' as ImageFormat)).toEqual({
88
+ format: 'png',
89
+ reason: 'backend-not-installed',
90
+ });
91
+ });
92
+
93
+ it('reports an unsupported format from the selected backend', () => {
94
+ setBitmapEncodeBackend(createBackend(['png']));
95
+ expect(explainBitmapEncodeFailure('jpeg')).toEqual({ format: 'jpeg', reason: 'format-unsupported' });
96
+ });
97
+
98
+ it('returns null for a supported format without invoking the encoder', () => {
99
+ const backend = createBackend(['png']);
100
+ setBitmapEncodeBackend(backend);
101
+ expect(explainBitmapEncodeFailure('png')).toBeNull();
102
+ expect(backend.encodeBitmap).not.toHaveBeenCalled();
17
103
  });
18
104
  });
@@ -0,0 +1,146 @@
1
+ import type { BitmapEncodeBackend } from '@flighthq/types/contract';
2
+
3
+ import {
4
+ explainBitmapEncodeBackend,
5
+ explainBitmapEncodeOperation,
6
+ getBitmapEncodeBackend,
7
+ hasBitmapEncodeHostBackend,
8
+ hasBitmapEncodeOperation,
9
+ installBitmapEncodeHostBackend,
10
+ observeBitmapEncodeHostResult,
11
+ resetBitmapEncodeBackendForTest,
12
+ setBitmapEncodeBackend,
13
+ } from './bitmapEncodeBackend';
14
+
15
+ function createBackend(supportedFormats: BitmapEncodeBackend['supportedFormats'] = ['jpeg', 'png']) {
16
+ return {
17
+ encodeBitmap: vi.fn(() => new Uint8Array([1])),
18
+ supportedFormats,
19
+ } satisfies BitmapEncodeBackend;
20
+ }
21
+
22
+ afterEach(() => {
23
+ resetBitmapEncodeBackendForTest();
24
+ });
25
+
26
+ describe('explainBitmapEncodeBackend', () => {
27
+ it('reports absence, host installation, and custom precedence', () => {
28
+ expect(explainBitmapEncodeBackend()).toEqual({
29
+ conflict: false,
30
+ layer: 'host-not-enabled',
31
+ operation: null,
32
+ viability: 'unobserved',
33
+ });
34
+ installBitmapEncodeHostBackend(createBackend());
35
+ expect(explainBitmapEncodeBackend()).toMatchObject({ layer: 'host' });
36
+ setBitmapEncodeBackend(createBackend());
37
+ expect(explainBitmapEncodeBackend()).toMatchObject({ layer: 'custom' });
38
+ });
39
+ });
40
+
41
+ describe('explainBitmapEncodeOperation', () => {
42
+ it('distinguishes sentinel, host, and custom layers', () => {
43
+ expect(explainBitmapEncodeOperation('encodeBitmap')).toEqual({
44
+ implemented: false,
45
+ layer: 'sentinel',
46
+ operation: 'encodeBitmap',
47
+ });
48
+ installBitmapEncodeHostBackend(createBackend());
49
+ expect(explainBitmapEncodeOperation('encodeBitmap')).toEqual({
50
+ implemented: true,
51
+ layer: 'host',
52
+ operation: 'encodeBitmap',
53
+ });
54
+ setBitmapEncodeBackend(createBackend());
55
+ expect(explainBitmapEncodeOperation('encodeBitmap')).toEqual({
56
+ implemented: true,
57
+ layer: 'custom',
58
+ operation: 'encodeBitmap',
59
+ });
60
+ });
61
+ });
62
+
63
+ describe('getBitmapEncodeBackend', () => {
64
+ it('returns custom over host and null when neither is installed', () => {
65
+ const host = createBackend();
66
+ const custom = createBackend();
67
+ expect(getBitmapEncodeBackend()).toBeNull();
68
+ installBitmapEncodeHostBackend(host);
69
+ expect(getBitmapEncodeBackend()).toBe(host);
70
+ setBitmapEncodeBackend(custom);
71
+ expect(getBitmapEncodeBackend()).toBe(custom);
72
+ setBitmapEncodeBackend(null);
73
+ expect(getBitmapEncodeBackend()).toBe(host);
74
+ });
75
+ });
76
+
77
+ describe('hasBitmapEncodeHostBackend', () => {
78
+ it('reports host occupancy independently of a custom backend', () => {
79
+ setBitmapEncodeBackend(createBackend());
80
+ expect(hasBitmapEncodeHostBackend()).toBe(false);
81
+ installBitmapEncodeHostBackend(createBackend());
82
+ expect(hasBitmapEncodeHostBackend()).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe('hasBitmapEncodeOperation', () => {
87
+ it('does not count the sentinel as support', () => {
88
+ expect(hasBitmapEncodeOperation('encodeBitmap')).toBe(false);
89
+ installBitmapEncodeHostBackend(createBackend());
90
+ expect(hasBitmapEncodeOperation('encodeBitmap')).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe('installBitmapEncodeHostBackend', () => {
95
+ it('keeps the first host and records a conflicting later identity', () => {
96
+ const first = createBackend();
97
+ installBitmapEncodeHostBackend(first);
98
+ installBitmapEncodeHostBackend(first);
99
+ expect(explainBitmapEncodeBackend().conflict).toBe(false);
100
+ installBitmapEncodeHostBackend(createBackend());
101
+ expect(getBitmapEncodeBackend()).toBe(first);
102
+ expect(explainBitmapEncodeBackend().conflict).toBe(true);
103
+ });
104
+ });
105
+
106
+ describe('observeBitmapEncodeHostResult', () => {
107
+ it.each([
108
+ [true, 'available'],
109
+ [false, 'runtime-api-unavailable'],
110
+ ] as const)('records host viability for result %s', (succeeded, viability) => {
111
+ installBitmapEncodeHostBackend(createBackend());
112
+ observeBitmapEncodeHostResult('encodeBitmap', succeeded);
113
+ expect(explainBitmapEncodeBackend()).toMatchObject({ operation: 'encodeBitmap', viability });
114
+ });
115
+ });
116
+
117
+ describe('resetBitmapEncodeBackendForTest', () => {
118
+ it('clears custom, host, conflict, and observation state', () => {
119
+ installBitmapEncodeHostBackend(createBackend());
120
+ installBitmapEncodeHostBackend(createBackend());
121
+ setBitmapEncodeBackend(createBackend());
122
+ observeBitmapEncodeHostResult('encodeBitmap', false);
123
+ resetBitmapEncodeBackendForTest();
124
+ expect(getBitmapEncodeBackend()).toBeNull();
125
+ expect(explainBitmapEncodeBackend()).toEqual({
126
+ conflict: false,
127
+ layer: 'host-not-enabled',
128
+ operation: null,
129
+ viability: 'unobserved',
130
+ });
131
+ });
132
+ });
133
+
134
+ describe('setBitmapEncodeBackend', () => {
135
+ it('replaces and clears only the custom layer', () => {
136
+ const host = createBackend();
137
+ const first = createBackend();
138
+ const second = createBackend();
139
+ installBitmapEncodeHostBackend(host);
140
+ setBitmapEncodeBackend(first);
141
+ setBitmapEncodeBackend(second);
142
+ expect(getBitmapEncodeBackend()).toBe(second);
143
+ setBitmapEncodeBackend(null);
144
+ expect(getBitmapEncodeBackend()).toBe(host);
145
+ });
146
+ });
@@ -1,28 +1,39 @@
1
1
  import { createImageResource } from '@flighthq/image/contract';
2
+ import type { BitmapReadbackBackend } from '@flighthq/types/contract';
2
3
  import { vi } from 'vitest';
3
4
 
4
5
  import { createBitmap } from './bitmap';
5
- import { createBitmapFromCanvas, captureBitmapFromImageResource, createBitmapFromImageSource } from './bitmapFrom';
6
+ import { captureBitmapFromImageResource, createBitmapFromCanvas, createBitmapFromImageSource } from './bitmapFrom';
7
+ import { resetBitmapReadbackBackendForTest, setBitmapReadbackBackend } from './bitmapReadbackBackend';
8
+
9
+ afterEach(() => {
10
+ resetBitmapReadbackBackendForTest();
11
+ });
6
12
 
7
13
  describe('captureBitmapFromImageResource', () => {
8
- it('reads a host-backed resource into CPU pixels', () => {
14
+ it('passes the resource source and dimensions through the selected readback backend', () => {
9
15
  const canvas = document.createElement('canvas');
10
16
  canvas.width = 3;
11
17
  canvas.height = 2;
12
- const bitmap = captureBitmapFromImageResource(createImageResource(canvas));
13
- expect(bitmap.width).toBe(3);
14
- expect(bitmap.height).toBe(2);
15
- expect(bitmap.data).toHaveLength(3 * 2 * 4);
18
+ const bitmap = createBitmap(3, 2);
19
+ const readBitmap = vi.fn(() => ({ bitmap, reason: 'ok' as const }));
20
+ setBitmapReadbackBackend({ readBitmap });
21
+
22
+ expect(captureBitmapFromImageResource(createImageResource(canvas))).toBe(bitmap);
23
+ expect(readBitmap).toHaveBeenCalledOnce();
24
+ expect(readBitmap).toHaveBeenCalledWith(canvas, 3, 2, 'bitmap');
16
25
  });
17
26
 
18
- it('returns Bitmap matching the resource dimensions', () => {
27
+ it('returns null for an expected backend refusal', () => {
28
+ const backend: BitmapReadbackBackend = {
29
+ readBitmap: vi.fn(() => ({ bitmap: null, reason: 'tainted-source' as const })),
30
+ };
31
+ setBitmapReadbackBackend(backend);
19
32
  const canvas = document.createElement('canvas');
20
33
  canvas.width = 4;
21
34
  canvas.height = 4;
22
- const resource = createImageResource(canvas);
23
- const data = captureBitmapFromImageResource(resource);
24
- expect(data.width).toBe(4);
25
- expect(data.height).toBe(4);
35
+
36
+ expect(captureBitmapFromImageResource(createImageResource(canvas))).toBeNull();
26
37
  });
27
38
  });
28
39
 
@@ -67,41 +78,17 @@ describe('createBitmapFromCanvas', () => {
67
78
  });
68
79
 
69
80
  describe('createBitmapFromImageSource', () => {
70
- it('captures a canvas image source at the given device size', () => {
71
- const canvas = document.createElement('canvas');
72
- canvas.width = 8;
73
- canvas.height = 4;
74
- const bitmap = createBitmapFromImageSource(canvas, 8, 4);
75
- expect(bitmap).not.toBeNull();
76
- expect(bitmap!.width).toBe(8);
77
- expect(bitmap!.height).toBe(4);
78
- expect(bitmap!.data.length).toBe(8 * 4 * 4);
79
- });
81
+ it('returns the exact Bitmap from the selected backend outcome', () => {
82
+ const expected = createBitmap(8, 4);
83
+ const backend: BitmapReadbackBackend = {
84
+ readBitmap: vi.fn(() => ({ bitmap: expected, reason: 'ok' as const })),
85
+ };
86
+ setBitmapReadbackBackend(backend);
80
87
 
81
- it('returns null rather than letting a tainted source throw', () => {
82
- // A cross-origin draw taints the scratch canvas and the platform refuses its pixels with a
83
- // SecurityError from getImageData. jsdom does not model tainting, so the throw is staged on
84
- // getImageData directly — which is the exact call and the exact exception the sentinel exists to
85
- // stop escaping to a caller who only asked for a bitmap.
86
- const canvas = document.createElement('canvas');
87
- canvas.width = 8;
88
- canvas.height = 4;
89
- const spy = vi.spyOn(CanvasRenderingContext2D.prototype, 'getImageData').mockImplementation(() => {
90
- throw new DOMException('Tainted canvases may not be exported.', 'SecurityError');
91
- });
92
- try {
93
- expect(() => createBitmapFromImageSource(canvas, 8, 4)).not.toThrow();
94
- expect(createBitmapFromImageSource(canvas, 8, 4)).toBeNull();
95
- } finally {
96
- spy.mockRestore();
97
- }
88
+ expect(createBitmapFromImageSource(document.createElement('canvas'), 8, 4)).toBe(expected);
98
89
  });
99
90
 
100
- it('returns null for an empty capture rather than allocating a zero-pixel bitmap', () => {
101
- const canvas = document.createElement('canvas');
102
- canvas.width = 8;
103
- canvas.height = 4;
104
- expect(createBitmapFromImageSource(canvas, 0, 4)).toBeNull();
105
- expect(createBitmapFromImageSource(canvas, 8, -1)).toBeNull();
91
+ it('returns null when no backend is installed', () => {
92
+ expect(createBitmapFromImageSource(document.createElement('canvas'), 8, 4)).toBeNull();
106
93
  });
107
94
  });
@@ -0,0 +1,69 @@
1
+ import type { BitmapReadbackBackend } from '@flighthq/types/contract';
2
+
3
+ import {
4
+ getBitmapReadbackBackend,
5
+ hasBitmapReadbackHostBackend,
6
+ installBitmapReadbackHostBackend,
7
+ resetBitmapReadbackBackendForTest,
8
+ setBitmapReadbackBackend,
9
+ } from './bitmapReadbackBackend';
10
+
11
+ afterEach(() => {
12
+ resetBitmapReadbackBackendForTest();
13
+ });
14
+
15
+ describe('getBitmapReadbackBackend', () => {
16
+ it('prefers a custom backend over the installed host backend', () => {
17
+ const host = createBackend();
18
+ const custom = createBackend();
19
+ installBitmapReadbackHostBackend(host);
20
+ setBitmapReadbackBackend(custom);
21
+
22
+ expect(getBitmapReadbackBackend()).toBe(custom);
23
+ });
24
+ });
25
+
26
+ describe('hasBitmapReadbackHostBackend', () => {
27
+ it('reports only host installation', () => {
28
+ setBitmapReadbackBackend(createBackend());
29
+ expect(hasBitmapReadbackHostBackend()).toBe(false);
30
+ installBitmapReadbackHostBackend(createBackend());
31
+ expect(hasBitmapReadbackHostBackend()).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe('installBitmapReadbackHostBackend', () => {
36
+ it('keeps the first installed host backend', () => {
37
+ const first = createBackend();
38
+ installBitmapReadbackHostBackend(first);
39
+ installBitmapReadbackHostBackend(createBackend());
40
+
41
+ expect(getBitmapReadbackBackend()).toBe(first);
42
+ });
43
+ });
44
+
45
+ describe('resetBitmapReadbackBackendForTest', () => {
46
+ it('clears custom and host backends', () => {
47
+ installBitmapReadbackHostBackend(createBackend());
48
+ setBitmapReadbackBackend(createBackend());
49
+
50
+ resetBitmapReadbackBackendForTest();
51
+
52
+ expect(getBitmapReadbackBackend()).toBeNull();
53
+ expect(hasBitmapReadbackHostBackend()).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe('setBitmapReadbackBackend', () => {
58
+ it('sets and clears the custom backend', () => {
59
+ const backend = createBackend();
60
+ setBitmapReadbackBackend(backend);
61
+ expect(getBitmapReadbackBackend()).toBe(backend);
62
+ setBitmapReadbackBackend(null);
63
+ expect(getBitmapReadbackBackend()).toBeNull();
64
+ });
65
+ });
66
+
67
+ function createBackend(): BitmapReadbackBackend {
68
+ return { readBitmap: () => ({ bitmap: null, reason: 'ok' }) };
69
+ }
@@ -0,0 +1,32 @@
1
+ import type { HostImageSource } from '@flighthq/types/contract';
2
+ import { vi } from 'vitest';
3
+
4
+ import { resetBitmapReadbackBackendForTest, setBitmapReadbackBackend } from './bitmapReadbackBackend';
5
+ import { resolveBitmapReadback } from './bitmapReadbackResolver';
6
+
7
+ afterEach(() => {
8
+ resetBitmapReadbackBackendForTest();
9
+ });
10
+
11
+ describe('resolveBitmapReadback', () => {
12
+ it('rejects empty dimensions before backend selection', () => {
13
+ expect(resolveBitmapReadback(source, 0, 1, 'bitmap')).toEqual({ bitmap: null, reason: 'empty-size' });
14
+ });
15
+
16
+ it('reports an absent backend', () => {
17
+ expect(resolveBitmapReadback(source, 1, 1, 'probe')).toEqual({
18
+ bitmap: null,
19
+ reason: 'backend-not-installed',
20
+ });
21
+ });
22
+
23
+ it('forwards source, dimensions, and mode to the selected backend', () => {
24
+ const readBitmap = vi.fn(() => ({ bitmap: null, reason: 'ok' as const }));
25
+ setBitmapReadbackBackend({ readBitmap });
26
+
27
+ expect(resolveBitmapReadback(source, 3, 5, 'probe')).toEqual({ bitmap: null, reason: 'ok' });
28
+ expect(readBitmap).toHaveBeenCalledWith(source, 3, 5, 'probe');
29
+ });
30
+ });
31
+
32
+ const source = {} as HostImageSource;