@nfcard/validation 0.12.0 → 0.13.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.d.ts +4647 -307
- package/dist/index.js +0 -0
- package/package.json +2 -2
- package/src/elements.test.ts +214 -214
- package/src/index.test.ts +26 -0
- package/src/index.ts +0 -0
- package/src/parity.test.ts +89 -0
- package/src/printability.ts +213 -213
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.
|
|
3
|
+
"version": "0.13.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.
|
|
20
|
+
"@nfcard/types": "0.12.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"tsup": "^8.0.0"
|
package/src/elements.test.ts
CHANGED
|
@@ -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 ─────────────────────────────────────────
|
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
|
+
})
|