@thermal-label/brother-ql-core 0.2.1 → 0.4.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.
Files changed (89) hide show
  1. package/README.md +1 -1
  2. package/data/devices.json +823 -0
  3. package/data/media.json +823 -0
  4. package/dist/__tests__/devices.test.js +112 -31
  5. package/dist/__tests__/devices.test.js.map +1 -1
  6. package/dist/__tests__/media.test.js +274 -4
  7. package/dist/__tests__/media.test.js.map +1 -1
  8. package/dist/__tests__/pack-bits.test.d.ts +2 -0
  9. package/dist/__tests__/pack-bits.test.d.ts.map +1 -0
  10. package/dist/__tests__/pack-bits.test.js +90 -0
  11. package/dist/__tests__/pack-bits.test.js.map +1 -0
  12. package/dist/__tests__/preview.test.js +1 -1
  13. package/dist/__tests__/preview.test.js.map +1 -1
  14. package/dist/__tests__/protocol.test.js +214 -2
  15. package/dist/__tests__/protocol.test.js.map +1 -1
  16. package/dist/__tests__/status.test.js +71 -0
  17. package/dist/__tests__/status.test.js.map +1 -1
  18. package/dist/devices.d.ts +14 -271
  19. package/dist/devices.d.ts.map +1 -1
  20. package/dist/devices.generated.d.ts +696 -0
  21. package/dist/devices.generated.d.ts.map +1 -0
  22. package/dist/devices.generated.js +831 -0
  23. package/dist/devices.generated.js.map +1 -0
  24. package/dist/devices.js +28 -273
  25. package/dist/devices.js.map +1 -1
  26. package/dist/index.d.ts +10 -9
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +6 -6
  29. package/dist/index.js.map +1 -1
  30. package/dist/media.d.ts +37 -10
  31. package/dist/media.d.ts.map +1 -1
  32. package/dist/media.generated.d.ts +4 -0
  33. package/dist/media.generated.d.ts.map +1 -0
  34. package/dist/media.generated.js +1640 -0
  35. package/dist/media.generated.js.map +1 -0
  36. package/dist/media.js +75 -264
  37. package/dist/media.js.map +1 -1
  38. package/dist/orientation.d.ts +11 -0
  39. package/dist/orientation.d.ts.map +1 -0
  40. package/dist/orientation.js +10 -0
  41. package/dist/orientation.js.map +1 -0
  42. package/dist/pack-bits.d.ts +20 -0
  43. package/dist/pack-bits.d.ts.map +1 -0
  44. package/dist/pack-bits.js +61 -0
  45. package/dist/pack-bits.js.map +1 -0
  46. package/dist/preview.d.ts +6 -6
  47. package/dist/preview.d.ts.map +1 -1
  48. package/dist/preview.js +11 -12
  49. package/dist/preview.js.map +1 -1
  50. package/dist/protocol.d.ts +54 -3
  51. package/dist/protocol.d.ts.map +1 -1
  52. package/dist/protocol.js +125 -20
  53. package/dist/protocol.js.map +1 -1
  54. package/dist/status.d.ts +5 -2
  55. package/dist/status.d.ts.map +1 -1
  56. package/dist/status.js +6 -3
  57. package/dist/status.js.map +1 -1
  58. package/dist/types.d.ts +106 -31
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js +1 -2
  61. package/dist/types.js.map +1 -1
  62. package/package.json +13 -9
  63. package/src/__tests__/devices.test.ts +122 -32
  64. package/src/__tests__/media.test.ts +312 -4
  65. package/src/__tests__/pack-bits.test.ts +92 -0
  66. package/src/__tests__/preview.test.ts +1 -1
  67. package/src/__tests__/protocol.test.ts +256 -1
  68. package/src/__tests__/status.test.ts +87 -0
  69. package/src/devices.generated.ts +840 -0
  70. package/src/devices.ts +31 -273
  71. package/src/index.ts +36 -8
  72. package/src/media.generated.ts +1644 -0
  73. package/src/media.ts +87 -264
  74. package/src/orientation.ts +11 -0
  75. package/src/pack-bits.ts +64 -0
  76. package/src/preview.ts +13 -12
  77. package/src/protocol.ts +204 -19
  78. package/src/status.ts +11 -5
  79. package/src/types.ts +113 -32
  80. package/dist/__tests__/colour.test.d.ts +0 -2
  81. package/dist/__tests__/colour.test.d.ts.map +0 -1
  82. package/dist/__tests__/colour.test.js +0 -106
  83. package/dist/__tests__/colour.test.js.map +0 -1
  84. package/dist/colour.d.ts +0 -26
  85. package/dist/colour.d.ts.map +0 -1
  86. package/dist/colour.js +0 -84
  87. package/dist/colour.js.map +0 -1
  88. package/src/__tests__/colour.test.ts +0 -126
  89. package/src/colour.ts +0 -101
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thermal-label/brother-ql-core",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Protocol encoding, device registry, and media registry for Brother QL label printers",
5
5
  "keywords": [
6
6
  "brother",
@@ -35,10 +35,12 @@
35
35
  "files": [
36
36
  "dist",
37
37
  "src",
38
+ "data/devices.json",
39
+ "data/media.json",
38
40
  "README.md"
39
41
  ],
40
42
  "engines": {
41
- "node": ">=24.0.0"
43
+ "node": ">=20.9.0"
42
44
  },
43
45
  "publishConfig": {
44
46
  "access": "public"
@@ -53,20 +55,22 @@
53
55
  }
54
56
  },
55
57
  "dependencies": {
56
- "@mbtech-nl/bitmap": "^0.1.0",
57
- "@thermal-label/contracts": "^0.1.2"
58
+ "@mbtech-nl/bitmap": "^1.3.0",
59
+ "@thermal-label/contracts": "^0.3.1"
58
60
  },
59
61
  "devDependencies": {
60
- "@mbtech-nl/tsconfig": "^1.0.0",
62
+ "@mbtech-nl/tsconfig": "^1.1.0",
61
63
  "@types/node": "^22.0.0",
62
64
  "@vitest/coverage-v8": "^2.1.9",
65
+ "json5": "^2.2.3",
63
66
  "typescript": "~5.5.0",
64
67
  "vitest": "^2.0.0"
65
68
  },
66
69
  "scripts": {
67
- "build": "tsc -p tsconfig.json",
68
- "typecheck": "tsc -p tsconfig.json --noEmit",
69
- "test": "vitest run",
70
- "test:coverage": "vitest run --coverage"
70
+ "compile-data": "node ../../scripts/compile-data.mjs",
71
+ "build": "pnpm run compile-data && tsc -p tsconfig.json",
72
+ "typecheck": "pnpm run compile-data && tsc -p tsconfig.json --noEmit",
73
+ "test": "pnpm run compile-data && vitest run",
74
+ "test:coverage": "pnpm run compile-data && vitest run --coverage"
71
75
  }
72
76
  }
@@ -1,26 +1,27 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { DEVICES, findDevice, isMassStorageMode } from '../devices.js';
2
+ import { DEVICES, findDevice, getUsbIds, isMassStorageMode } from '../devices.js';
3
3
 
4
4
  describe('findDevice', () => {
5
- it('returns correct descriptor for QL-820NWB', () => {
6
- const dev = findDevice(0x04f9, 0x20a7);
5
+ it('returns correct entry for QL-820NWBc (PID shared with QL-820NWB)', () => {
6
+ const dev = findDevice(0x04f9, 0x209d);
7
7
  expect(dev).toBeDefined();
8
- expect(dev!.name).toBe('QL-820NWB');
9
- expect(dev!.twoColor).toBe(true);
8
+ expect(dev!.name).toBe('QL-820NWBc');
9
+ expect(dev!.engines[0]?.capabilities?.twoColor).toBe(true);
10
10
  });
11
11
 
12
- it('returns correct descriptor for QL-820NWBc', () => {
13
- const dev = findDevice(0x04f9, 0x209d);
12
+ it('returns correct entry for QL-1100', () => {
13
+ const dev = findDevice(0x04f9, 0x20a7);
14
14
  expect(dev).toBeDefined();
15
- expect(dev!.name).toBe('QL-820NWBc');
16
- expect(dev!.twoColor).toBe(true);
15
+ expect(dev!.name).toBe('QL-1100');
16
+ expect(dev!.engines[0]?.headDots).toBe(1296);
17
17
  });
18
18
 
19
- it('returns correct descriptor for QL-500', () => {
19
+ it('returns correct entry for QL-500', () => {
20
20
  const dev = findDevice(0x04f9, 0x2013);
21
21
  expect(dev).toBeDefined();
22
22
  expect(dev!.name).toBe('QL-500');
23
- expect(dev!.autocut).toBe(false);
23
+ // QL-500 has no autocut — the capability flag is absent.
24
+ expect(dev!.engines[0]?.capabilities?.autocut).toBeUndefined();
24
25
  });
25
26
 
26
27
  it('returns undefined for unknown PID', () => {
@@ -28,39 +29,44 @@ describe('findDevice', () => {
28
29
  });
29
30
 
30
31
  it('returns undefined for unknown VID', () => {
31
- expect(findDevice(0x1234, 0x20a7)).toBeUndefined();
32
+ expect(findDevice(0x1234, 0x209d)).toBeUndefined();
32
33
  });
33
34
  });
34
35
 
35
36
  describe('isMassStorageMode', () => {
36
- it('returns true for 0x20AA (QL-1100 mass storage)', () => {
37
+ it('returns true for 0x20a9 (QL-1100 mass storage)', () => {
38
+ expect(isMassStorageMode(0x20a9)).toBe(true);
39
+ });
40
+
41
+ it('returns true for 0x20aa (QL-1110NWB mass storage)', () => {
37
42
  expect(isMassStorageMode(0x20aa)).toBe(true);
38
43
  });
39
44
 
40
- it('returns true for 0x20AB (QL-1110NWB mass storage)', () => {
41
- expect(isMassStorageMode(0x20ab)).toBe(true);
45
+ it('returns true for 0x20ac (QL-1115NWB mass storage)', () => {
46
+ expect(isMassStorageMode(0x20ac)).toBe(true);
42
47
  });
43
48
 
44
49
  it('returns false for all printer-class PIDs', () => {
45
50
  for (const dev of Object.values(DEVICES)) {
46
- expect(isMassStorageMode(dev.pid)).toBe(false);
51
+ const ids = getUsbIds(dev);
52
+ if (ids) expect(isMassStorageMode(ids.pid)).toBe(false);
47
53
  }
48
54
  });
49
55
  });
50
56
 
51
57
  describe('Device registry invariants', () => {
52
- it('every two-color device has bytesPerRow 90', () => {
58
+ it('every two-color device has a 720-dot engine', () => {
53
59
  for (const dev of Object.values(DEVICES)) {
54
- if (dev.twoColor) {
55
- expect(dev.bytesPerRow).toBe(90);
60
+ if (dev.engines[0]?.capabilities?.twoColor) {
61
+ expect(dev.engines[0].headDots).toBe(720);
56
62
  }
57
63
  }
58
64
  });
59
65
 
60
- it('every device with headPins 1296 has bytesPerRow 162', () => {
66
+ it('every device with headDots 1296 belongs to the QL-1xxx series', () => {
61
67
  for (const dev of Object.values(DEVICES)) {
62
- if (dev.headPins === 1296) {
63
- expect(dev.bytesPerRow).toBe(162);
68
+ if (dev.engines[0]?.headDots === 1296) {
69
+ expect(dev.name).toMatch(/^QL-1\d{3}/);
64
70
  }
65
71
  }
66
72
  });
@@ -71,19 +77,103 @@ describe('Device registry invariants', () => {
71
77
  }
72
78
  });
73
79
 
74
- it('QL-820NWB(c) advertise serial/web-serial for OS-paired Bluetooth', () => {
75
- for (const key of ['QL_820NWB', 'QL_820NWBc'] as const) {
76
- const dev = DEVICES[key];
77
- expect(dev.transports).toContain('serial');
78
- expect(dev.transports).toContain('web-serial');
80
+ it('every device declares a USB transport with hex-string vid+pid', () => {
81
+ for (const dev of Object.values(DEVICES)) {
82
+ expect(dev.transports.usb).toBeDefined();
83
+ expect(dev.transports.usb!.vid).toMatch(/^0x[0-9a-f]+$/);
84
+ expect(dev.transports.usb!.pid).toMatch(/^0x[0-9a-f]+$/);
79
85
  }
80
86
  });
87
+ });
81
88
 
82
- it('no device descriptor declares web-bluetooth', () => {
83
- // Bluetooth on the 820 series is classic SPP, not GATT — the
84
- // serial transports cover it. See packages/core/src/types.ts.
85
- for (const dev of Object.values(DEVICES)) {
86
- expect(dev.transports).not.toContain('web-bluetooth');
89
+ describe('PT-* device entries', () => {
90
+ const PT_KEYS = ['PT_E550W', 'PT_P750W', 'PT_P900', 'PT_P900W', 'PT_P950NW', 'PT_P910BT'];
91
+
92
+ it('every PT entry resolves by (vid, pid)', () => {
93
+ const expected: Record<string, number> = {
94
+ PT_E550W: 0x2060,
95
+ PT_P750W: 0x2062,
96
+ PT_P900: 0x2083,
97
+ PT_P900W: 0x2085,
98
+ PT_P950NW: 0x2086,
99
+ PT_P910BT: 0x20c7,
100
+ };
101
+ for (const key of PT_KEYS) {
102
+ const pid = expected[key]!;
103
+ const dev = findDevice(0x04f9, pid);
104
+ expect(dev, key).toBeDefined();
105
+ expect(dev!.key).toBe(key);
106
+ }
107
+ });
108
+
109
+ it('every PT engine uses the pt-raster protocol', () => {
110
+ for (const key of PT_KEYS) {
111
+ const dev = DEVICES[key as keyof typeof DEVICES];
112
+ expect(dev.engines[0]?.protocol, key).toBe('pt-raster');
113
+ }
114
+ });
115
+
116
+ it('every PT engine has headDots in {128, 560}', () => {
117
+ for (const key of PT_KEYS) {
118
+ const dev = DEVICES[key as keyof typeof DEVICES];
119
+ expect([128, 560]).toContain(dev.engines[0]?.headDots);
120
+ }
121
+ });
122
+
123
+ it('PT high-res dpi is exactly 2× the native dpi', () => {
124
+ for (const key of PT_KEYS) {
125
+ const dev = DEVICES[key as keyof typeof DEVICES];
126
+ const engine = dev.engines[0]!;
127
+ const dpi = engine.dpi;
128
+ const highResDpi = engine.capabilities?.highResDpi as number | undefined;
129
+ expect(highResDpi, `${key} highResDpi`).toBeDefined();
130
+ expect(highResDpi).toBe(dpi * 2);
131
+ }
132
+ });
133
+
134
+ it('128-dot family is 180 dpi, 560-dot family is 360 dpi', () => {
135
+ for (const key of PT_KEYS) {
136
+ const dev = DEVICES[key as keyof typeof DEVICES];
137
+ const engine = dev.engines[0]!;
138
+ if (engine.headDots === 128) expect(engine.dpi).toBe(180);
139
+ else if (engine.headDots === 560) expect(engine.dpi).toBe(360);
87
140
  }
88
141
  });
142
+
143
+ it('every PT entry ships untested', () => {
144
+ for (const key of PT_KEYS) {
145
+ const dev = DEVICES[key as keyof typeof DEVICES];
146
+ expect(dev.support.status).toBe('untested');
147
+ }
148
+ });
149
+
150
+ it('PT-P910BT is TZe-only (no HSe in mediaCompatibility)', () => {
151
+ const dev = DEVICES.PT_P910BT;
152
+ expect(dev.engines[0]?.mediaCompatibility).toEqual(['tze']);
153
+ });
154
+
155
+ it('all other PT models declare TZe + HSe 2:1 + HSe 3:1', () => {
156
+ for (const key of ['PT_E550W', 'PT_P750W', 'PT_P900', 'PT_P900W', 'PT_P950NW']) {
157
+ const dev = DEVICES[key as keyof typeof DEVICES];
158
+ expect(dev.engines[0]?.mediaCompatibility, key).toEqual(['tze', 'hse-2to1', 'hse-3to1']);
159
+ }
160
+ });
161
+
162
+ it('PT-P750W carries both printer PID 0x2062 and mass-storage PID 0x2065', () => {
163
+ const dev = DEVICES.PT_P750W;
164
+ expect(dev.transports.usb?.pid).toBe('0x2062');
165
+ expect(dev.capabilities?.massStoragePid).toBe('0x2065');
166
+ expect(isMassStorageMode(0x2065)).toBe(true);
167
+ });
168
+
169
+ it('PT-P910BT declares bluetooth-spp transport', () => {
170
+ const dev = DEVICES.PT_P910BT;
171
+ expect(dev.transports['bluetooth-spp']).toBeDefined();
172
+ expect(dev.transports['bluetooth-spp']?.namePrefix).toBe('PT-P910');
173
+ });
174
+
175
+ it('PT-P900 is USB-only (no tcp / bluetooth)', () => {
176
+ const dev = DEVICES.PT_P900;
177
+ expect(Object.keys(dev.transports).sort()).toEqual(['usb']);
178
+ });
89
179
  });
@@ -1,5 +1,35 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { MEDIA, findMedia, findMediaByWidth } from '../media.js';
2
+ import { mediaCompatibleWith, type PrintEngine } from '@thermal-label/contracts';
3
+ import {
4
+ MEDIA,
5
+ defaultMediaForEngine,
6
+ findMedia,
7
+ findMediaByDimensions,
8
+ findMediaByWidth,
9
+ resolveTapeGeometry,
10
+ } from '../media.js';
11
+ import { DEVICES } from '../devices.js';
12
+
13
+ const NARROW_PT_ENGINE = {
14
+ protocol: 'pt-raster' as const,
15
+ headDots: 128,
16
+ mediaCompatibility: ['tze', 'hse-2to1', 'hse-3to1'],
17
+ };
18
+ const WIDE_PT_ENGINE = {
19
+ protocol: 'pt-raster' as const,
20
+ headDots: 560,
21
+ mediaCompatibility: ['tze', 'hse-2to1', 'hse-3to1'],
22
+ };
23
+ const TZE_ONLY_PT_ENGINE = {
24
+ protocol: 'pt-raster' as const,
25
+ headDots: 560,
26
+ mediaCompatibility: ['tze'],
27
+ };
28
+ const QL_ENGINE = {
29
+ protocol: 'ql-raster' as const,
30
+ headDots: 720,
31
+ mediaCompatibility: ['dk'],
32
+ };
3
33
 
4
34
  describe('findMedia', () => {
5
35
  it('returns correct descriptor for 62mm continuous (ID 259)', () => {
@@ -63,19 +93,297 @@ describe('Media registry invariants', () => {
63
93
  }
64
94
  });
65
95
 
66
- it('only DK-22251 is colorCapable', () => {
96
+ it('only DK-22251 carries a multi-ink palette', () => {
67
97
  for (const m of Object.values(MEDIA)) {
68
98
  if (m.id === 251) {
69
- expect(m.colorCapable).toBe(true);
99
+ expect(m.palette).toBeDefined();
100
+ const palette = m.palette!;
101
+ expect(palette).toHaveLength(2);
102
+ expect(palette[0]!.name).toBe('black');
103
+ expect(palette[1]!.name).toBe('red');
70
104
  } else {
71
- expect(m.colorCapable).toBe(false);
105
+ expect(m.palette).toBeUndefined();
72
106
  }
73
107
  }
74
108
  });
75
109
 
110
+ it('rectangular die-cut entries declare defaultOrientation: horizontal', () => {
111
+ const rectangularDieCutIds = [269, 270, 370, 271, 272, 367, 374, 274, 275, 365, 366];
112
+ for (const id of rectangularDieCutIds) {
113
+ const m = MEDIA[id]!;
114
+ expect(m, `entry ${id.toString()}`).toBeDefined();
115
+ expect(m.defaultOrientation, `entry ${id.toString()}`).toBe('horizontal');
116
+ }
117
+ });
118
+
119
+ it('round die-cut entries set cornerRadiusMm to widthMm / 2', () => {
120
+ const roundDieCutIds = [362, 363, 273];
121
+ for (const id of roundDieCutIds) {
122
+ const m = MEDIA[id]!;
123
+ expect(m, `entry ${id.toString()}`).toBeDefined();
124
+ expect(m.cornerRadiusMm, `entry ${id.toString()}`).toBe(m.widthMm / 2);
125
+ }
126
+ });
127
+
76
128
  it('all IDs are unique', () => {
77
129
  const ids = Object.values(MEDIA).map(m => m.id);
78
130
  const unique = new Set(ids);
79
131
  expect(unique.size).toBe(ids.length);
80
132
  });
133
+
134
+ it('every entry declares a tape system', () => {
135
+ for (const m of Object.values(MEDIA)) {
136
+ expect(m.tapeSystem, `entry ${m.id.toString()}`).toBeDefined();
137
+ }
138
+ });
139
+
140
+ it('TZe ids occupy the 401-419 range, HSe 421-459', () => {
141
+ for (const m of Object.values(MEDIA)) {
142
+ if (m.tapeSystem === 'tze') {
143
+ expect(m.id, m.name).toBeGreaterThanOrEqual(401);
144
+ expect(m.id, m.name).toBeLessThanOrEqual(419);
145
+ } else if (m.tapeSystem === 'hse-2to1') {
146
+ expect(m.id, m.name).toBeGreaterThanOrEqual(421);
147
+ expect(m.id, m.name).toBeLessThanOrEqual(439);
148
+ } else if (m.tapeSystem === 'hse-3to1') {
149
+ expect(m.id, m.name).toBeGreaterThanOrEqual(441);
150
+ expect(m.id, m.name).toBeLessThanOrEqual(459);
151
+ } else {
152
+ expect(m.tapeSystem, m.name).toBe('dk');
153
+ }
154
+ }
155
+ });
156
+
157
+ it('TZe / HSe pin sums equal the head-family pin count', () => {
158
+ for (const m of Object.values(MEDIA)) {
159
+ if (m.tapeSystem === 'dk') continue;
160
+ if (m.geometry?.narrow) {
161
+ const { leftMarginPins, printAreaDots, rightMarginPins } = m.geometry.narrow;
162
+ expect(leftMarginPins + printAreaDots + rightMarginPins, `${m.name} narrow head sum`).toBe(
163
+ 128,
164
+ );
165
+ }
166
+ if (m.geometry?.wide) {
167
+ const { leftMarginPins, printAreaDots, rightMarginPins } = m.geometry.wide;
168
+ expect(leftMarginPins + printAreaDots + rightMarginPins, `${m.name} wide head sum`).toBe(
169
+ 560,
170
+ );
171
+ }
172
+ }
173
+ });
174
+
175
+ it('36 mm TZe and 31 mm HSe-3:1 are wide-head only', () => {
176
+ expect(MEDIA[407]!.geometry?.narrow).toBeUndefined();
177
+ expect(MEDIA[407]!.geometry?.wide).toBeDefined();
178
+ expect(MEDIA[445]!.geometry?.narrow).toBeUndefined();
179
+ expect(MEDIA[445]!.geometry?.wide).toBeDefined();
180
+ });
181
+ });
182
+
183
+ describe('findMediaByDimensions — engine gating', () => {
184
+ it('legacy call (no engine) keeps DK-only behaviour for 12 mm continuous', () => {
185
+ const m = findMediaByDimensions(12, 0);
186
+ expect(m).toBeDefined();
187
+ expect(m!.tapeSystem).toBe('dk');
188
+ expect(m!.id).toBe(257);
189
+ });
190
+
191
+ it('QL engine for 12 mm returns the DK entry, never a TZe entry', () => {
192
+ const m = findMediaByDimensions(12, 0, false, QL_ENGINE);
193
+ expect(m!.tapeSystem).toBe('dk');
194
+ expect(m!.id).toBe(257);
195
+ });
196
+
197
+ it('narrow PT engine for 12 mm returns the 128-dot TZe entry', () => {
198
+ const m = findMediaByDimensions(12, 0, false, NARROW_PT_ENGINE);
199
+ expect(m!.tapeSystem).toBe('tze');
200
+ expect(m!.id).toBe(404);
201
+ expect(m!.geometry?.narrow).toBeDefined();
202
+ });
203
+
204
+ it('wide PT engine for 12 mm returns the same TZe row (geometry resolves later)', () => {
205
+ const m = findMediaByDimensions(12, 0, false, WIDE_PT_ENGINE);
206
+ expect(m!.tapeSystem).toBe('tze');
207
+ expect(m!.id).toBe(404);
208
+ expect(m!.geometry?.wide).toBeDefined();
209
+ });
210
+
211
+ it('narrow PT engine cannot reach 36 mm TZe', () => {
212
+ const m = findMediaByDimensions(36, 0, false, NARROW_PT_ENGINE);
213
+ expect(m).toBeUndefined();
214
+ });
215
+
216
+ it('wide PT engine resolves 36 mm TZe', () => {
217
+ const m = findMediaByDimensions(36, 0, false, WIDE_PT_ENGINE);
218
+ expect(m!.id).toBe(407);
219
+ });
220
+
221
+ it('narrow PT engine cannot reach 31 mm HSe-3:1', () => {
222
+ const m = findMediaByDimensions(31, 0, false, NARROW_PT_ENGINE);
223
+ expect(m).toBeUndefined();
224
+ });
225
+
226
+ it('wide PT engine resolves 31 mm HSe-3:1', () => {
227
+ const m = findMediaByDimensions(31, 0, false, WIDE_PT_ENGINE);
228
+ expect(m!.id).toBe(445);
229
+ });
230
+
231
+ it('TZe-only PT engine never returns HSe entries', () => {
232
+ expect(findMediaByDimensions(11.7, 0, false, TZE_ONLY_PT_ENGINE)).toBeUndefined();
233
+ expect(findMediaByDimensions(5.2, 0, false, TZE_ONLY_PT_ENGINE)).toBeUndefined();
234
+ expect(findMediaByDimensions(31, 0, false, TZE_ONLY_PT_ENGINE)).toBeUndefined();
235
+ });
236
+
237
+ it('TZe-only PT engine still resolves TZe widths', () => {
238
+ const m = findMediaByDimensions(24, 0, false, TZE_ONLY_PT_ENGINE);
239
+ expect(m!.tapeSystem).toBe('tze');
240
+ });
241
+ });
242
+
243
+ describe('resolveTapeGeometry', () => {
244
+ it('returns flat fields for DK entries regardless of engine head dots', () => {
245
+ const dk = MEDIA[259]!;
246
+ const geom = resolveTapeGeometry(dk, { headDots: 720 });
247
+ expect(geom.printAreaDots).toBe(696);
248
+ });
249
+
250
+ it('routes TZe through narrow / wide based on engine.headDots', () => {
251
+ const tze12 = MEDIA[404]!;
252
+ const narrow = resolveTapeGeometry(tze12, { headDots: 128 });
253
+ expect(narrow.printAreaDots).toBe(70);
254
+ const wide = resolveTapeGeometry(tze12, { headDots: 560 });
255
+ expect(wide.printAreaDots).toBe(150);
256
+ });
257
+
258
+ it('throws when the requested head family has no geometry', () => {
259
+ expect(() => resolveTapeGeometry(MEDIA[407]!, { headDots: 128 })).toThrow(/narrow/);
260
+ expect(() => resolveTapeGeometry(MEDIA[445]!, { headDots: 128 })).toThrow(/narrow/);
261
+ });
262
+ });
263
+
264
+ describe('defaultMediaForEngine', () => {
265
+ it('returns DK-22205 for QL engines', () => {
266
+ expect(defaultMediaForEngine({ protocol: 'ql-raster' }).id).toBe(259);
267
+ });
268
+
269
+ it('returns 12 mm TZe for PT engines', () => {
270
+ expect(defaultMediaForEngine({ protocol: 'pt-raster' }).id).toBe(404);
271
+ });
272
+ });
273
+
274
+ describe('Substrate-gate field-shape invariants', () => {
275
+ it('every entry declares a non-empty targetModels', () => {
276
+ for (const m of Object.values(MEDIA)) {
277
+ expect(m.targetModels, m.name).toBeDefined();
278
+ expect(m.targetModels!.length, m.name).toBeGreaterThan(0);
279
+ }
280
+ });
281
+
282
+ it('every entry declares a category', () => {
283
+ for (const m of Object.values(MEDIA)) {
284
+ expect(m.category, m.name).toBeDefined();
285
+ }
286
+ });
287
+
288
+ it('targetModels is consistent with tapeSystem', () => {
289
+ for (const m of Object.values(MEDIA)) {
290
+ // DK rolls allow either 'dk' or 'dk-wide' in targetModels — both
291
+ // are DK-substrate tags. Other tape systems carry the bare value.
292
+ if (m.tapeSystem === 'dk') {
293
+ const ok = m.targetModels!.includes('dk') || m.targetModels!.includes('dk-wide');
294
+ expect(ok, `${m.name} targetModels=${JSON.stringify(m.targetModels)}`).toBe(true);
295
+ } else {
296
+ expect(m.targetModels, m.name).toContain(m.tapeSystem);
297
+ }
298
+ }
299
+ });
300
+
301
+ it('every DK row with widthMm > 62 is tagged dk-wide', () => {
302
+ for (const m of Object.values(MEDIA)) {
303
+ if (m.tapeSystem === 'dk' && m.widthMm > 62) {
304
+ expect(m.targetModels, `${m.name} (id ${m.id.toString()})`).toContain('dk-wide');
305
+ }
306
+ }
307
+ });
308
+
309
+ it('every QL-1xxx engine accepts both dk and dk-wide', () => {
310
+ const wideKeys = ['QL_1050', 'QL_1060N', 'QL_1100', 'QL_1110NWB', 'QL_1115NWB'] as const;
311
+ for (const key of wideKeys) {
312
+ const compat = DEVICES[key].engines[0]!.mediaCompatibility;
313
+ expect(compat, key).toContain('dk');
314
+ expect(compat, key).toContain('dk-wide');
315
+ }
316
+ });
317
+
318
+ it('no narrow QL chassis lists dk-wide', () => {
319
+ for (const d of Object.values(DEVICES)) {
320
+ if (!d.key.startsWith('QL_')) continue;
321
+ // The five 1296-dot chassis are the wide tier; everything else is 720-dot.
322
+ const headDots = d.engines[0]!.headDots;
323
+ if (headDots === 720) {
324
+ expect(d.engines[0]!.mediaCompatibility, d.key).not.toContain('dk-wide');
325
+ }
326
+ }
327
+ });
328
+ });
329
+
330
+ describe('Substrate-gate enforcement matrix (mediaCompatibleWith)', () => {
331
+ // Representative engines for each compatibility class. The matrix
332
+ // walks every media row against each and asserts the cell — catches
333
+ // a row losing its targetModels (would falsely match every engine),
334
+ // an HSe row mistagged 'tze', a 102 mm row losing 'dk-wide', or a
335
+ // future engine dropping the substrate tag.
336
+ const CLASSES: Record<string, PrintEngine> = {
337
+ qlStandard: DEVICES.QL_700.engines[0]!,
338
+ qlWide: DEVICES.QL_1100.engines[0]!,
339
+ ptTzeHse: DEVICES.PT_P900.engines[0]!,
340
+ ptTzeOnly: DEVICES.PT_P910BT.engines[0]!,
341
+ };
342
+
343
+ type ClassKey = keyof typeof CLASSES;
344
+ // For a media row, returns the set of class keys that should accept it.
345
+ function expectedClassesFor(m: {
346
+ tapeSystem: string;
347
+ targetModels?: readonly string[];
348
+ }): Set<ClassKey> {
349
+ const isWide = m.targetModels?.includes('dk-wide') ?? false;
350
+ const set = new Set<ClassKey>();
351
+ if (m.tapeSystem === 'dk') {
352
+ if (isWide) {
353
+ set.add('qlWide');
354
+ } else {
355
+ set.add('qlStandard');
356
+ set.add('qlWide');
357
+ }
358
+ } else if (m.tapeSystem === 'tze') {
359
+ set.add('ptTzeHse');
360
+ set.add('ptTzeOnly');
361
+ } else if (m.tapeSystem === 'hse-2to1' || m.tapeSystem === 'hse-3to1') {
362
+ set.add('ptTzeHse');
363
+ }
364
+ return set;
365
+ }
366
+
367
+ it('every (engine class, media row) cell matches expectation', () => {
368
+ for (const m of Object.values(MEDIA)) {
369
+ const expected = expectedClassesFor(m);
370
+ for (const key of Object.keys(CLASSES)) {
371
+ const got = mediaCompatibleWith(m, CLASSES[key]!);
372
+ const want = expected.has(key);
373
+ expect(got, `${m.name} (id ${m.id.toString()}) on ${key}`).toBe(want);
374
+ }
375
+ }
376
+ });
377
+
378
+ it('PT-P910BT does not surface HSe media', () => {
379
+ // Strictly redundant with the matrix above, but worth keeping
380
+ // separately so a future bisect on a "P910BT shows HSe media"
381
+ // report finds a test named after the case.
382
+ const engine = DEVICES.PT_P910BT.engines[0]!;
383
+ for (const m of Object.values(MEDIA)) {
384
+ if (m.tapeSystem === 'hse-2to1' || m.tapeSystem === 'hse-3to1') {
385
+ expect(mediaCompatibleWith(m, engine), m.name).toBe(false);
386
+ }
387
+ }
388
+ });
81
389
  });
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { packBits } from '../pack-bits.js';
3
+
4
+ // Reference TIFF PackBits decoder. Used to round-trip arbitrary inputs and
5
+ // confirm the encoder emits a stream the canonical algorithm can decode.
6
+ function unpackBits(input: Uint8Array): Uint8Array {
7
+ const out: number[] = [];
8
+ let i = 0;
9
+ while (i < input.length) {
10
+ const header = input[i]!;
11
+ const signed = header > 127 ? header - 256 : header;
12
+ if (signed >= 0) {
13
+ const n = signed + 1;
14
+ for (let j = 0; j < n; j++) out.push(input[i + 1 + j]!);
15
+ i += 1 + n;
16
+ } else if (signed >= -127) {
17
+ const n = 1 - signed;
18
+ for (let j = 0; j < n; j++) out.push(input[i + 1]!);
19
+ i += 2;
20
+ } else {
21
+ i++; // signed === -128: no-op
22
+ }
23
+ }
24
+ return new Uint8Array(out);
25
+ }
26
+
27
+ describe('packBits', () => {
28
+ it('emits empty output for empty input', () => {
29
+ expect(packBits(new Uint8Array(0)).length).toBe(0);
30
+ });
31
+
32
+ it('encodes a single byte as a 1-byte literal', () => {
33
+ expect(Array.from(packBits(new Uint8Array([0x42])))).toEqual([0x00, 0x42]);
34
+ });
35
+
36
+ it('encodes a 2-byte repeat as 2 wire bytes', () => {
37
+ // -(2-1) = -1 → 0xFF
38
+ expect(Array.from(packBits(new Uint8Array([0x42, 0x42])))).toEqual([0xff, 0x42]);
39
+ });
40
+
41
+ it('compresses an all-zero 90-byte row to 2 bytes (the common case)', () => {
42
+ const result = packBits(new Uint8Array(90));
43
+ // -(90-1) = -89 = 0xA7
44
+ expect(Array.from(result)).toEqual([0xa7, 0x00]);
45
+ });
46
+
47
+ it('caps repeat runs at 128 bytes — 130 zeros split into 128 + 2', () => {
48
+ const result = packBits(new Uint8Array(130));
49
+ // -(128-1) = -127 = 0x81; -(2-1) = -1 = 0xFF
50
+ expect(Array.from(result)).toEqual([0x81, 0x00, 0xff, 0x00]);
51
+ });
52
+
53
+ it('caps literal runs at 128 bytes', () => {
54
+ // 130 distinct bytes split into 128 + 2 literals.
55
+ const input = new Uint8Array(130);
56
+ for (let i = 0; i < 130; i++) input[i] = i & 0xff;
57
+ const result = packBits(input);
58
+ expect(result[0]).toBe(0x7f); // header for 128 literals
59
+ expect(result[129]).toBe(0x01); // header for 2 literals
60
+ expect(result.length).toBe(132);
61
+ });
62
+
63
+ it('mixes literal and repeat runs', () => {
64
+ // A A B C D D D E
65
+ const input = new Uint8Array([0x10, 0x10, 0x20, 0x30, 0x40, 0x40, 0x40, 0x50]);
66
+ expect(Array.from(packBits(input))).toEqual([
67
+ 0xff,
68
+ 0x10, // -1: 2 repeats of 0x10
69
+ 0x01,
70
+ 0x20,
71
+ 0x30, // 1: 2 literals
72
+ 0xfe,
73
+ 0x40, // -2: 3 repeats of 0x40
74
+ 0x00,
75
+ 0x50, // 0: 1 literal
76
+ ]);
77
+ });
78
+
79
+ it('round-trips a label-like row (margins + bar + scattered) through the spec decoder', () => {
80
+ // Mimic a typical raster row: 12 zero-byte margin, 50 bytes of 0xFF
81
+ // (solid bar), 12 mixed bytes (text), 16 zero-byte trailing margin.
82
+ const original = new Uint8Array(90);
83
+ for (let i = 12; i < 62; i++) original[i] = 0xff;
84
+ for (let i = 62; i < 74; i++) original[i] = (i * 7) & 0xff;
85
+
86
+ const compressed = packBits(original);
87
+ const decoded = unpackBits(compressed);
88
+ expect(Array.from(decoded)).toEqual(Array.from(original));
89
+ // And it actually saves bytes — the whole point.
90
+ expect(compressed.length).toBeLessThan(original.length);
91
+ });
92
+ });