@nfcard/validation 0.12.0 → 0.14.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.
package/dist/index.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nfcard/validation",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Shared Zod validation schemas for the NFCard product family.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^3.24.0",
20
- "@nfcard/types": "0.11.0"
20
+ "@nfcard/types": "0.13.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "tsup": "^8.0.0"
@@ -1,214 +1,214 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { cardDesignSchema, cardAssetSchema } from './index'
3
- import {
4
- CHIP_SCALE_FLOOR,
5
- QR_MIN_SIZE_MM,
6
- chipAnchorToMm,
7
- mmToNearestChipAnchor,
8
- elementPrintability,
9
- type ChipAxis,
10
- } from './printability'
11
-
12
- // A legacy (grid-only) 3D card design — unchanged shape, no elements[].
13
- const legacyDesign = {
14
- material: '3dprint_standard',
15
- designType: 'custom',
16
- thicknessMm: 1.8,
17
- pattern: null,
18
- layout: { chipAnchor: { col: 0, row: 1 } },
19
- filaments: { background: '#F2F1ED', text: '#1A1A1A', accent: '#3FA34D' },
20
- }
21
-
22
- const chipEl = (over = {}) => ({
23
- id: 'chip1',
24
- type: 'chip',
25
- shape: 0,
26
- transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1, z: 0 },
27
- ...over,
28
- })
29
- const textEl = (over = {}) => ({
30
- id: 't1',
31
- type: 'text',
32
- text: 'Anna Kovács',
33
- sizeMm: 4.4,
34
- weight: 700,
35
- role: 'text',
36
- transform: { xMm: -18, yMm: -14, rotationDeg: 0, scale: 1, z: 1 },
37
- ...over,
38
- })
39
- const qrEl = (over = {}) => ({
40
- id: 'q1',
41
- type: 'qr',
42
- url: 'https://nfcard.hu',
43
- sizeMm: 22,
44
- frameRole: 'accent',
45
- moduleRole: 'text',
46
- transform: { xMm: 26, yMm: 12, rotationDeg: 0, scale: 1, z: 2 },
47
- ...over,
48
- })
49
-
50
- // A valid CUSTOM design with a free-transform element layer. The chip element sits
51
- // dead-centre, so the dual-written layout.chipAnchor must be {1,1}.
52
- const withElements = (elements: unknown[], over = {}) => ({
53
- ...legacyDesign,
54
- contractVersion: 2,
55
- layout: { chipAnchor: { col: 1, row: 1 } },
56
- elements,
57
- ...over,
58
- })
59
-
60
- const codes = (res: ReturnType<typeof cardDesignSchema.safeParse>): string[] =>
61
- res.success ? [] : res.error.issues.map((i) => i.message)
62
-
63
- describe('cardDesignSchema — free-transform element layer (NFCARD-197/198)', () => {
64
- it('legacy design with no elements still validates (zero regression)', () => {
65
- expect(cardDesignSchema.safeParse(legacyDesign).success).toBe(true)
66
- })
67
-
68
- it('accepts a valid custom design with chip + text + qr elements', () => {
69
- const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl(), qrEl()]))
70
- expect(res.success).toBe(true)
71
- })
72
-
73
- it('rejects a chip scaled below the NFC-coil floor (grow-only)', () => {
74
- const res = cardDesignSchema.safeParse(withElements([chipEl({ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 0.5, z: 0 } })]))
75
- expect(res.success).toBe(false)
76
- expect(codes(res)).toContain('chipScaleBelowCoil')
77
- })
78
-
79
- it('accepts a chip scaled UP (decorative ring)', () => {
80
- const res = cardDesignSchema.safeParse(withElements([chipEl({ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1.3, z: 0 } })]))
81
- expect(res.success).toBe(true)
82
- })
83
-
84
- it('rejects a QR below the scannable size floor', () => {
85
- const res = cardDesignSchema.safeParse(withElements([chipEl(), qrEl({ sizeMm: 12 })]))
86
- expect(res.success).toBe(false)
87
- expect(codes(res)).toContain('qrTooSmall')
88
- })
89
-
90
- it('rejects a raster image asset on a 3D card (vector-only)', () => {
91
- const res = cardDesignSchema.safeParse(
92
- withElements(
93
- [chipEl(), { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }],
94
- { assets: [{ id: 'a1', kind: 'raster', rasterDataUrl: 'data:image/png;base64,AAAA' }] },
95
- ),
96
- )
97
- expect(res.success).toBe(false)
98
- expect(codes(res)).toContain('rasterImageOn3d')
99
- })
100
-
101
- it('accepts a vector image asset on a 3D card', () => {
102
- const res = cardDesignSchema.safeParse(
103
- withElements(
104
- [chipEl(), { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }],
105
- { assets: [{ id: 'a1', kind: 'vector', svg: '<svg/>' }] },
106
- ),
107
- )
108
- expect(res.success).toBe(true)
109
- })
110
-
111
- it('rejects an element referencing a missing asset', () => {
112
- const res = cardDesignSchema.safeParse(
113
- withElements([chipEl(), { id: 'img1', type: 'image', assetRef: 'nope', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }]),
114
- )
115
- expect(res.success).toBe(false)
116
- expect(codes(res)).toContain('assetMissing')
117
- })
118
-
119
- it('rejects elements on a template (templates are fixed — custom-only)', () => {
120
- const res = cardDesignSchema.safeParse(withElements([chipEl()], { designType: 'template', templateId: 'tpl-3d-flat' }))
121
- expect(res.success).toBe(false)
122
- expect(codes(res)).toContain('elementsCustomOnly')
123
- })
124
-
125
- it('rejects elements without contractVersion 2', () => {
126
- const res = cardDesignSchema.safeParse(withElements([chipEl()], { contractVersion: undefined }))
127
- expect(res.success).toBe(false)
128
- expect(codes(res)).toContain('contractVersionRequired')
129
- })
130
-
131
- it('rejects a chipAnchor inconsistent with the chip element (dual-write tamper)', () => {
132
- const res = cardDesignSchema.safeParse(withElements([chipEl()], { layout: { chipAnchor: { col: 0, row: 0 } } }))
133
- expect(res.success).toBe(false)
134
- expect(codes(res)).toContain('chipAnchorDualWriteMismatch')
135
- })
136
-
137
- it('requires exactly one chip element', () => {
138
- const none = cardDesignSchema.safeParse(withElements([textEl()]))
139
- expect(codes(none)).toContain('exactlyOneChip')
140
- const two = cardDesignSchema.safeParse(withElements([chipEl(), chipEl({ id: 'chip2' })]))
141
- expect(codes(two)).toContain('exactlyOneChip')
142
- })
143
-
144
- it('rejects an element that extends off the card', () => {
145
- const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ transform: { xMm: 60, yMm: 0, rotationDeg: 0, scale: 1, z: 1 } })]))
146
- expect(res.success).toBe(false)
147
- expect(codes(res)).toContain('elementOffCard')
148
- })
149
-
150
- it('rejects duplicate element ids', () => {
151
- const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ id: 'chip1' })]))
152
- expect(res.success).toBe(false)
153
- expect(codes(res)).toContain('duplicateElementId')
154
- })
155
-
156
- it('rejects empty text', () => {
157
- const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ text: ' ' })]))
158
- expect(res.success).toBe(false)
159
- expect(codes(res)).toContain('textEmpty')
160
- })
161
- })
162
-
163
- describe('printability helpers', () => {
164
- it('chip scale floor is just under 1 (grow-only, coil-bound)', () => {
165
- expect(CHIP_SCALE_FLOOR).toBeGreaterThan(0.95)
166
- expect(CHIP_SCALE_FLOOR).toBeLessThan(1)
167
- })
168
-
169
- it('QR min size keeps the module at the scan floor', () => {
170
- expect(QR_MIN_SIZE_MM).toBe(20)
171
- })
172
-
173
- it('mmToNearestChipAnchor inverts chipAnchorToMm for all 9 cells', () => {
174
- for (const col of [0, 1, 2] as ChipAxis[]) {
175
- for (const row of [0, 1, 2] as ChipAxis[]) {
176
- const mm = chipAnchorToMm(col, row)
177
- expect(mmToNearestChipAnchor(mm)).toEqual({ col, row })
178
- }
179
- }
180
- })
181
-
182
- it('a centred chip projects to the centre cell', () => {
183
- expect(mmToNearestChipAnchor({ x: 0, y: 0 })).toEqual({ col: 1, row: 1 })
184
- })
185
-
186
- it('elementPrintability passes a legal centred chip', () => {
187
- const chip = chipEl() as Parameters<typeof elementPrintability>[0]
188
- expect(elementPrintability(chip, { assetsById: new Map() })).toEqual([])
189
- })
190
- })
191
-
192
- describe('per-element override fields survive validation (#1  P0 contract)', () => {
193
- it('cardDesign RETAINS colour / italic / underline / bgRemoved / svgOverrides + vector-asset svg fields', () => {
194
- const parsed = cardDesignSchema.parse(
195
- withElements(
196
- [
197
- chipEl({ colour: 'black' }),
198
- textEl({ colour: 'red', italic: true, underline: true }),
199
- { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', bgRemoved: true, svgOverrides: ['green', undefined], transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } },
200
- ],
201
- { assets: [{ id: 'a1', kind: 'vector', svg: '<svg/>', svgColours: ['#ff0000', '#00ff00'], svgFils: ['red', 'green'] }] },
202
- ),
203
- ) as { elements: Record<string, unknown>[]; assets: Record<string, unknown>[] }
204
- expect(parsed.elements[0].colour).toBe('black')
205
- expect(parsed.elements[1]).toMatchObject({ colour: 'red', italic: true, underline: true })
206
- expect(parsed.elements[2]).toMatchObject({ bgRemoved: true, svgOverrides: ['green', undefined] })
207
- expect(parsed.assets[0]).toMatchObject({ svgColours: ['#ff0000', '#00ff00'], svgFils: ['red', 'green'] })
208
- })
209
-
210
- it('cardAssetSchema retains the raster bg-removal + aspect fields (PVC preview)', () => {
211
- const a = cardAssetSchema.parse({ id: 'r1', kind: 'raster', rasterDataUrl: 'data:,', rasterBgDataUrl: 'data:,bg', rasterAspect: 1.5 })
212
- expect(a).toMatchObject({ rasterBgDataUrl: 'data:,bg', rasterAspect: 1.5 })
213
- })
214
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cardDesignSchema, cardAssetSchema } from './index'
3
+ import {
4
+ CHIP_SCALE_FLOOR,
5
+ QR_MIN_SIZE_MM,
6
+ chipAnchorToMm,
7
+ mmToNearestChipAnchor,
8
+ elementPrintability,
9
+ type ChipAxis,
10
+ } from './printability'
11
+
12
+ // A legacy (grid-only) 3D card design — unchanged shape, no elements[].
13
+ const legacyDesign = {
14
+ material: '3dprint_standard',
15
+ designType: 'custom',
16
+ thicknessMm: 1.8,
17
+ pattern: null,
18
+ layout: { chipAnchor: { col: 0, row: 1 } },
19
+ filaments: { background: '#F2F1ED', text: '#1A1A1A', accent: '#3FA34D' },
20
+ }
21
+
22
+ const chipEl = (over = {}) => ({
23
+ id: 'chip1',
24
+ type: 'chip',
25
+ shape: 0,
26
+ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1, z: 0 },
27
+ ...over,
28
+ })
29
+ const textEl = (over = {}) => ({
30
+ id: 't1',
31
+ type: 'text',
32
+ text: 'Anna Kovács',
33
+ sizeMm: 4.4,
34
+ weight: 700,
35
+ role: 'text',
36
+ transform: { xMm: -18, yMm: -14, rotationDeg: 0, scale: 1, z: 1 },
37
+ ...over,
38
+ })
39
+ const qrEl = (over = {}) => ({
40
+ id: 'q1',
41
+ type: 'qr',
42
+ url: 'https://nfcard.hu',
43
+ sizeMm: 22,
44
+ frameRole: 'accent',
45
+ moduleRole: 'text',
46
+ transform: { xMm: 26, yMm: 12, rotationDeg: 0, scale: 1, z: 2 },
47
+ ...over,
48
+ })
49
+
50
+ // A valid CUSTOM design with a free-transform element layer. The chip element sits
51
+ // dead-centre, so the dual-written layout.chipAnchor must be {1,1}.
52
+ const withElements = (elements: unknown[], over = {}) => ({
53
+ ...legacyDesign,
54
+ contractVersion: 2,
55
+ layout: { chipAnchor: { col: 1, row: 1 } },
56
+ elements,
57
+ ...over,
58
+ })
59
+
60
+ const codes = (res: ReturnType<typeof cardDesignSchema.safeParse>): string[] =>
61
+ res.success ? [] : res.error.issues.map((i) => i.message)
62
+
63
+ describe('cardDesignSchema — free-transform element layer (NFCARD-197/198)', () => {
64
+ it('legacy design with no elements still validates (zero regression)', () => {
65
+ expect(cardDesignSchema.safeParse(legacyDesign).success).toBe(true)
66
+ })
67
+
68
+ it('accepts a valid custom design with chip + text + qr elements', () => {
69
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl(), qrEl()]))
70
+ expect(res.success).toBe(true)
71
+ })
72
+
73
+ it('rejects a chip scaled below the NFC-coil floor (grow-only)', () => {
74
+ const res = cardDesignSchema.safeParse(withElements([chipEl({ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 0.5, z: 0 } })]))
75
+ expect(res.success).toBe(false)
76
+ expect(codes(res)).toContain('chipScaleBelowCoil')
77
+ })
78
+
79
+ it('accepts a chip scaled UP (decorative ring)', () => {
80
+ const res = cardDesignSchema.safeParse(withElements([chipEl({ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1.3, z: 0 } })]))
81
+ expect(res.success).toBe(true)
82
+ })
83
+
84
+ it('rejects a QR below the scannable size floor', () => {
85
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), qrEl({ sizeMm: 12 })]))
86
+ expect(res.success).toBe(false)
87
+ expect(codes(res)).toContain('qrTooSmall')
88
+ })
89
+
90
+ it('rejects a raster image asset on a 3D card (vector-only)', () => {
91
+ const res = cardDesignSchema.safeParse(
92
+ withElements(
93
+ [chipEl(), { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }],
94
+ { assets: [{ id: 'a1', kind: 'raster', rasterDataUrl: 'data:image/png;base64,AAAA' }] },
95
+ ),
96
+ )
97
+ expect(res.success).toBe(false)
98
+ expect(codes(res)).toContain('rasterImageOn3d')
99
+ })
100
+
101
+ it('accepts a vector image asset on a 3D card', () => {
102
+ const res = cardDesignSchema.safeParse(
103
+ withElements(
104
+ [chipEl(), { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }],
105
+ { assets: [{ id: 'a1', kind: 'vector', svg: '<svg/>' }] },
106
+ ),
107
+ )
108
+ expect(res.success).toBe(true)
109
+ })
110
+
111
+ it('rejects an element referencing a missing asset', () => {
112
+ const res = cardDesignSchema.safeParse(
113
+ withElements([chipEl(), { id: 'img1', type: 'image', assetRef: 'nope', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }]),
114
+ )
115
+ expect(res.success).toBe(false)
116
+ expect(codes(res)).toContain('assetMissing')
117
+ })
118
+
119
+ it('rejects elements on a template (templates are fixed — custom-only)', () => {
120
+ const res = cardDesignSchema.safeParse(withElements([chipEl()], { designType: 'template', templateId: 'tpl-3d-flat' }))
121
+ expect(res.success).toBe(false)
122
+ expect(codes(res)).toContain('elementsCustomOnly')
123
+ })
124
+
125
+ it('rejects elements without contractVersion 2', () => {
126
+ const res = cardDesignSchema.safeParse(withElements([chipEl()], { contractVersion: undefined }))
127
+ expect(res.success).toBe(false)
128
+ expect(codes(res)).toContain('contractVersionRequired')
129
+ })
130
+
131
+ it('rejects a chipAnchor inconsistent with the chip element (dual-write tamper)', () => {
132
+ const res = cardDesignSchema.safeParse(withElements([chipEl()], { layout: { chipAnchor: { col: 0, row: 0 } } }))
133
+ expect(res.success).toBe(false)
134
+ expect(codes(res)).toContain('chipAnchorDualWriteMismatch')
135
+ })
136
+
137
+ it('requires exactly one chip element', () => {
138
+ const none = cardDesignSchema.safeParse(withElements([textEl()]))
139
+ expect(codes(none)).toContain('exactlyOneChip')
140
+ const two = cardDesignSchema.safeParse(withElements([chipEl(), chipEl({ id: 'chip2' })]))
141
+ expect(codes(two)).toContain('exactlyOneChip')
142
+ })
143
+
144
+ it('rejects an element that extends off the card', () => {
145
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ transform: { xMm: 60, yMm: 0, rotationDeg: 0, scale: 1, z: 1 } })]))
146
+ expect(res.success).toBe(false)
147
+ expect(codes(res)).toContain('elementOffCard')
148
+ })
149
+
150
+ it('rejects duplicate element ids', () => {
151
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ id: 'chip1' })]))
152
+ expect(res.success).toBe(false)
153
+ expect(codes(res)).toContain('duplicateElementId')
154
+ })
155
+
156
+ it('rejects empty text', () => {
157
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ text: ' ' })]))
158
+ expect(res.success).toBe(false)
159
+ expect(codes(res)).toContain('textEmpty')
160
+ })
161
+ })
162
+
163
+ describe('printability helpers', () => {
164
+ it('chip scale floor is just under 1 (grow-only, coil-bound)', () => {
165
+ expect(CHIP_SCALE_FLOOR).toBeGreaterThan(0.95)
166
+ expect(CHIP_SCALE_FLOOR).toBeLessThan(1)
167
+ })
168
+
169
+ it('QR min size keeps the module at the scan floor', () => {
170
+ expect(QR_MIN_SIZE_MM).toBe(20)
171
+ })
172
+
173
+ it('mmToNearestChipAnchor inverts chipAnchorToMm for all 9 cells', () => {
174
+ for (const col of [0, 1, 2] as ChipAxis[]) {
175
+ for (const row of [0, 1, 2] as ChipAxis[]) {
176
+ const mm = chipAnchorToMm(col, row)
177
+ expect(mmToNearestChipAnchor(mm)).toEqual({ col, row })
178
+ }
179
+ }
180
+ })
181
+
182
+ it('a centred chip projects to the centre cell', () => {
183
+ expect(mmToNearestChipAnchor({ x: 0, y: 0 })).toEqual({ col: 1, row: 1 })
184
+ })
185
+
186
+ it('elementPrintability passes a legal centred chip', () => {
187
+ const chip = chipEl() as Parameters<typeof elementPrintability>[0]
188
+ expect(elementPrintability(chip, { assetsById: new Map() })).toEqual([])
189
+ })
190
+ })
191
+
192
+ describe('per-element override fields survive validation (#1  P0 contract)', () => {
193
+ it('cardDesign RETAINS colour / italic / underline / bgRemoved / svgOverrides + vector-asset svg fields', () => {
194
+ const parsed = cardDesignSchema.parse(
195
+ withElements(
196
+ [
197
+ chipEl({ colour: 'black' }),
198
+ textEl({ colour: 'red', italic: true, underline: true }),
199
+ { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', bgRemoved: true, svgOverrides: ['green', undefined], transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } },
200
+ ],
201
+ { assets: [{ id: 'a1', kind: 'vector', svg: '<svg/>', svgColours: ['#ff0000', '#00ff00'], svgFils: ['red', 'green'] }] },
202
+ ),
203
+ ) as { elements: Record<string, unknown>[]; assets: Record<string, unknown>[] }
204
+ expect(parsed.elements[0].colour).toBe('black')
205
+ expect(parsed.elements[1]).toMatchObject({ colour: 'red', italic: true, underline: true })
206
+ expect(parsed.elements[2]).toMatchObject({ bgRemoved: true, svgOverrides: ['green', undefined] })
207
+ expect(parsed.assets[0]).toMatchObject({ svgColours: ['#ff0000', '#00ff00'], svgFils: ['red', 'green'] })
208
+ })
209
+
210
+ it('cardAssetSchema retains the raster bg-removal + aspect fields (PVC preview)', () => {
211
+ const a = cardAssetSchema.parse({ id: 'r1', kind: 'raster', rasterDataUrl: 'data:,', rasterBgDataUrl: 'data:,bg', rasterAspect: 1.5 })
212
+ expect(a).toMatchObject({ rasterBgDataUrl: 'data:,bg', rasterAspect: 1.5 })
213
+ })
214
+ })
package/src/index.test.ts CHANGED
@@ -490,6 +490,32 @@ describe('physicalConfigSchema / validatePhysicalConfig', () => {
490
490
  });
491
491
  expect(r.success).toBe(false);
492
492
  });
493
+
494
+ it('accepts + RETAINS top-level elements/assets for a PVC (2D) custom design', () => {
495
+ // PVC parity: the free-transform layer + raster image assets ride top-level
496
+ // on a plastic config (which has no cardDesign) and must NOT be stripped by
497
+ // the strict object parse — otherwise the order persists no renderable design.
498
+ const r = physicalConfigSchema.safeParse({
499
+ ...validPhysicalConfig,
500
+ elements: [
501
+ {
502
+ id: 'img1',
503
+ type: 'image',
504
+ assetRef: 'a1',
505
+ widthMm: 20,
506
+ heightMm: 12,
507
+ role: 'text',
508
+ transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 },
509
+ },
510
+ ],
511
+ assets: [{ id: 'a1', kind: 'raster', rasterDataUrl: 'https://cdn.example/card.webp' }],
512
+ });
513
+ expect(r.success).toBe(true);
514
+ if (r.success) {
515
+ expect(r.data.elements).toHaveLength(1);
516
+ expect(r.data.assets?.[0]?.rasterDataUrl).toBe('https://cdn.example/card.webp');
517
+ }
518
+ });
493
519
  });
494
520
 
495
521
  // ── digitalConfigSchema ─────────────────────────────────────────
@@ -523,6 +549,13 @@ describe('digitalConfigSchema / validateDigitalConfig', () => {
523
549
  expect(r.success).toBe(true);
524
550
  });
525
551
 
552
+ it('accepts the new family templateIds (classic/spotlight/marquee/riso)', () => {
553
+ for (const templateId of ['classic', 'spotlight', 'marquee', 'riso']) {
554
+ const r = validateDigitalConfig({ ...validDigitalConfig, templateId });
555
+ expect(r.success).toBe(true);
556
+ }
557
+ });
558
+
526
559
  it('rejects an empty fontKey', () => {
527
560
  const r = validateDigitalConfig({ ...validDigitalConfig, fontKey: '' });
528
561
  expect(r.success).toBe(false);
package/src/index.ts CHANGED
Binary file
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync, existsSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import type { CardElement, CardAsset } from '@nfcard/types'
5
+ import {
6
+ elementPrintability,
7
+ chipAnchorToMm,
8
+ obbCorners,
9
+ CHIP_SCALE_FLOOR,
10
+ QR_MIN_SIZE_MM,
11
+ type ChipAxis,
12
+ } from './printability'
13
+
14
+ /**
15
+ * Cross-language PLACEMENT + PRINTABILITY parity — the TS half of the guard
16
+ * (NFCARD-247, doc 83).
17
+ *
18
+ * `cardgen.validate` (`validate_elements` / `obb_corners`) and `cardgen.pattern`
19
+ * (`chip_centre_for_anchor`) are by-hand Python ports of THIS file. The golden lives
20
+ * in the cardgen repo (its pytest, `test_parity_printability.py`, is the other half).
21
+ * This test pins printability.ts to that same committed golden, so a change to the
22
+ * gate, the chip Y-frame projection, or the OBB/rotation-sign formula here fails
23
+ * LOUDLY with a reminder to regenerate and update the Python side in lockstep —
24
+ * instead of silently drifting the printed card away from the live preview.
25
+ *
26
+ * Regenerate (both sides together):
27
+ * node ../../nfcard-cardgen/tests/parity/gen_printability_parity.mjs
28
+ *
29
+ * Skips cleanly if nfcard-cardgen isn't checked out alongside (shared-only CI).
30
+ */
31
+ const GOLDEN = fileURLToPath(
32
+ new URL('../../../nfcard-cardgen/tests/parity/printability_parity.json', import.meta.url),
33
+ )
34
+ const present = existsSync(GOLDEN)
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ const golden: any = present ? JSON.parse(readFileSync(GOLDEN, 'utf-8')) : { elementCases: [] }
37
+ const TOL = 1e-9
38
+
39
+ // Mirror cardgen `validate_elements`: per-element printability + the graph-level
40
+ // exactly-one-chip rule (printability.ts leaves that to the schema superRefine).
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ function expectedCodes(design: any): string[] {
43
+ const assetsById = new Map<string, CardAsset>(
44
+ (design.assets ?? []).map((a: CardAsset) => [a.id, a]),
45
+ )
46
+ const codes: string[] = []
47
+ let chipCount = 0
48
+ for (const el of design.elements as CardElement[]) {
49
+ if (el.type === 'chip') chipCount += 1
50
+ for (const v of elementPrintability(el, { assetsById })) codes.push(v.code)
51
+ }
52
+ if (chipCount !== 1) codes.push('chipCount')
53
+ return codes.sort()
54
+ }
55
+
56
+ describe.skipIf(!present)('printability parity golden (locks the TS source of truth)', () => {
57
+ it('shares the threshold constants the Python gate branches on', () => {
58
+ expect(golden.constants.chipScaleFloor).toBeCloseTo(CHIP_SCALE_FLOOR, 9)
59
+ expect(golden.constants.qrMinSizeMm).toBe(QR_MIN_SIZE_MM)
60
+ })
61
+
62
+ it('elementPrintability still reproduces the committed Violation codes', () => {
63
+ expect(golden.elementCases.length, 'golden has cases — regenerate if 0').toBeGreaterThan(0)
64
+ for (const c of golden.elementCases) {
65
+ expect(expectedCodes(c.design), `${c.name}: code drift — regenerate the golden`).toEqual(
66
+ c.expectedCodes,
67
+ )
68
+ }
69
+ })
70
+
71
+ it('chipAnchorToMm still reproduces the committed 3×3 grid → mm (the Y-frame)', () => {
72
+ for (const e of golden.chipAnchorMm) {
73
+ const p = chipAnchorToMm(e.col as ChipAxis, e.row as ChipAxis)
74
+ expect(Math.abs(p.x - e.x), `chip(${e.col},${e.row}).x`).toBeLessThan(TOL)
75
+ expect(Math.abs(p.y - e.y), `chip(${e.col},${e.row}).y`).toBeLessThan(TOL)
76
+ }
77
+ })
78
+
79
+ it('obbCorners still reproduces the committed corners (the rotation-sign)', () => {
80
+ for (const c of golden.obbCases) {
81
+ const got = obbCorners(c.transform, c.w, c.h)
82
+ expect(got.length).toBe(c.corners.length)
83
+ got.forEach((p, i) => {
84
+ expect(Math.abs(p.x - c.corners[i][0]), `${c.name} corner ${i}.x`).toBeLessThan(TOL)
85
+ expect(Math.abs(p.y - c.corners[i][1]), `${c.name} corner ${i}.y`).toBeLessThan(TOL)
86
+ })
87
+ }
88
+ })
89
+ })