@nfcard/validation 0.25.0 → 0.26.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 +75 -2
- package/dist/index.js +31 -5
- package/package.json +2 -2
- package/src/booking.test.ts +85 -85
- package/src/controlChars.test.ts +71 -0
- package/src/index.ts +0 -0
- package/src/link.test.ts +96 -0
- package/src/parity.test.ts +25 -6
- package/src/printability.test.ts +47 -0
- package/src/printability.ts +29 -2
- package/src/tipJar.test.ts +77 -77
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _nfcard_types from '@nfcard/types';
|
|
2
|
-
import { CardAsset,
|
|
2
|
+
import { CardAsset, ElementTransform, CardElement, ProfileObjectKind, ProfileLayout } from '@nfcard/types';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -47,6 +47,21 @@ interface Pt {
|
|
|
47
47
|
declare function isFiniteTransform(t: ElementTransform): boolean;
|
|
48
48
|
/** The 4 corners of a `wMm × hMm` box (centre-origin local) under a transform. */
|
|
49
49
|
declare function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[];
|
|
50
|
+
/** Is a DISC of radius `rMm` centred at `t` inside the ID-1 rim minus the safety margin?
|
|
51
|
+
*
|
|
52
|
+
* NFCARD-841 — the chip is physically a DISC, but {@link footprintOf} gives it the 27.5 mm
|
|
53
|
+
* bounding SQUARE, so running it through {@link cornersOnCard} + {@link obbCorners} made its
|
|
54
|
+
* on-card verdict ROTATION-SENSITIVE. A circle covers the same area at every angle, so that is
|
|
55
|
+
* wrong in principle; it was also wrong in practice, because past scale ≈1.31 the square's
|
|
56
|
+
* half-diagonal (13.75·s·√2) exceeds the 25.5 mm rim and a 45°-rotated chip was rejected at
|
|
57
|
+
* EVERY position on the card — unorderable with no placement that could save it.
|
|
58
|
+
*
|
|
59
|
+
* ⚠ At rotation 0 this is EXACTLY the old bound (the square's half-height IS the radius), so no
|
|
60
|
+
* previously-legal design changes verdict and no previously-illegal one is let through. The only
|
|
61
|
+
* behaviour that moves is the rotation artifact.
|
|
62
|
+
*
|
|
63
|
+
* Rotation-invariant by construction — it takes no angle. Mirrors cardgen `disc_on_card`. */
|
|
64
|
+
declare function discOnCard(t: ElementTransform, rMm: number, margin?: number): boolean;
|
|
50
65
|
/** Are all corners inside the ID-1 rim minus the safety margin? */
|
|
51
66
|
declare function cornersOnCard(corners: Pt[], margin?: number): boolean;
|
|
52
67
|
type ChipAxis = 0 | 1 | 2;
|
|
@@ -22722,5 +22737,63 @@ declare function validateUserData(data: unknown): ValidationResult<z.infer<typeo
|
|
|
22722
22737
|
declare function validatePhysicalConfig(data: unknown): ValidationResult<z.infer<typeof physicalConfigSchema>>;
|
|
22723
22738
|
declare function validateDigitalConfig(data: unknown): ValidationResult<z.infer<typeof digitalConfigSchema>>;
|
|
22724
22739
|
declare function validateConfiguration(data: unknown): ValidationResult<z.infer<typeof configurationCreateSchema>>;
|
|
22740
|
+
/** What physical thing carries the code. Fulfilment dispatch, never routing. */
|
|
22741
|
+
declare const productTypeSchema: z.ZodEnum<["card", "qr_sticker", "keychain", "board"]>;
|
|
22742
|
+
/** Where a tap goes. */
|
|
22743
|
+
declare const linkDestinationTypeSchema: z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>;
|
|
22744
|
+
/** The lifecycle of the code itself, independent of where it points. */
|
|
22745
|
+
declare const linkStatusSchema: z.ZodEnum<["minted", "unclaimed", "claimed", "active", "void"]>;
|
|
22746
|
+
/** Which touchpoint of one physical object a row is. */
|
|
22747
|
+
declare const linkRoleSchema: z.ZodEnum<["chip", "qr"]>;
|
|
22748
|
+
/**
|
|
22749
|
+
* The three destination columns, as a request body carries them.
|
|
22750
|
+
*
|
|
22751
|
+
* SHAPE ONLY. Which types a given caller may actually SET is a tier question,
|
|
22752
|
+
* answered server-side against a tier the server derives itself — putting it
|
|
22753
|
+
* here would mean a client could read the gate off the schema, and a schema
|
|
22754
|
+
* cannot see who is asking.
|
|
22755
|
+
*
|
|
22756
|
+
* `destUrl` is capped but not URL-validated on purpose: the single gate is the
|
|
22757
|
+
* server's external-URL parser, and a second, looser check here would only
|
|
22758
|
+
* teach a client that a URL it will later refuse was accepted.
|
|
22759
|
+
*/
|
|
22760
|
+
declare const MAX_LINK_DEST_URL_LENGTH = 2048;
|
|
22761
|
+
declare const linkDestinationInputSchema: z.ZodObject<{
|
|
22762
|
+
destinationType: z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>;
|
|
22763
|
+
destProfileId: z.ZodOptional<z.ZodString>;
|
|
22764
|
+
destUrl: z.ZodOptional<z.ZodString>;
|
|
22765
|
+
}, "strip", z.ZodTypeAny, {
|
|
22766
|
+
destinationType: "auto" | "board" | "profile" | "hub_chooser" | "external";
|
|
22767
|
+
destProfileId?: string | undefined;
|
|
22768
|
+
destUrl?: string | undefined;
|
|
22769
|
+
}, {
|
|
22770
|
+
destinationType: "auto" | "board" | "profile" | "hub_chooser" | "external";
|
|
22771
|
+
destProfileId?: string | undefined;
|
|
22772
|
+
destUrl?: string | undefined;
|
|
22773
|
+
}>;
|
|
22774
|
+
type LinkDestinationInput = z.infer<typeof linkDestinationInputSchema>;
|
|
22775
|
+
/**
|
|
22776
|
+
* A destination the buyer chose BEFORE the object existed.
|
|
22777
|
+
*
|
|
22778
|
+
* Every field optional, including the discriminator, because "the buyer never
|
|
22779
|
+
* opened the picker" is the overwhelmingly common state and has to be storable
|
|
22780
|
+
* as silence rather than as an explicit default. Consumed once at fulfilment,
|
|
22781
|
+
* where it may only ever SOFTEN — a routing preference must not fail a paid
|
|
22782
|
+
* order.
|
|
22783
|
+
*/
|
|
22784
|
+
declare const linkDestinationIntentSchema: z.ZodObject<{
|
|
22785
|
+
destinationType: z.ZodOptional<z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>>;
|
|
22786
|
+
destProfileId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
22787
|
+
destUrl: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
22788
|
+
}, "strip", z.ZodTypeAny, {
|
|
22789
|
+
destinationType?: "auto" | "board" | "profile" | "hub_chooser" | "external" | undefined;
|
|
22790
|
+
destProfileId?: string | undefined;
|
|
22791
|
+
destUrl?: string | undefined;
|
|
22792
|
+
}, {
|
|
22793
|
+
destinationType?: "auto" | "board" | "profile" | "hub_chooser" | "external" | undefined;
|
|
22794
|
+
destProfileId?: string | undefined;
|
|
22795
|
+
destUrl?: string | undefined;
|
|
22796
|
+
}>;
|
|
22797
|
+
type LinkDestinationIntent = z.infer<typeof linkDestinationIntentSchema>;
|
|
22725
22798
|
|
|
22726
|
-
export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, MAX_CTA_BUTTONS, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, STYLE_SCHEMA_FOR_KIND, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, rowTextStyleSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
|
|
22799
|
+
export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, type LinkDestinationInput, type LinkDestinationIntent, MAX_CTA_BUTTONS, MAX_LINK_DEST_URL_LENGTH, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, STYLE_SCHEMA_FOR_KIND, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, discOnCard, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, linkDestinationInputSchema, linkDestinationIntentSchema, linkDestinationTypeSchema, linkRoleSchema, linkStatusSchema, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, productTypeSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, rowTextStyleSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
isTipProviderId,
|
|
5
5
|
normalizeTipHandle,
|
|
6
6
|
isBookingProviderId,
|
|
7
|
-
normalizeBookingHandle
|
|
7
|
+
normalizeBookingHandle,
|
|
8
|
+
PRODUCT_TYPES,
|
|
9
|
+
LINK_DESTINATION_TYPES,
|
|
10
|
+
LINK_STATUSES,
|
|
11
|
+
LINK_ROLES
|
|
8
12
|
} from "@nfcard/types";
|
|
9
13
|
|
|
10
14
|
// src/printability.ts
|
|
@@ -41,6 +45,9 @@ function obbCorners(t, wMm, hMm) {
|
|
|
41
45
|
];
|
|
42
46
|
return local.map(([x, y]) => ({ x: t.xMm + x * c - y * s, y: t.yMm + x * s + y * c }));
|
|
43
47
|
}
|
|
48
|
+
function discOnCard(t, rMm, margin = EDGE_MARGIN_MM) {
|
|
49
|
+
return Math.abs(t.xMm) + rMm <= CARD_W_MM / 2 - margin + EPS && Math.abs(t.yMm) + rMm <= CARD_H_MM / 2 - margin + EPS;
|
|
50
|
+
}
|
|
44
51
|
function cornersOnCard(corners, margin = EDGE_MARGIN_MM) {
|
|
45
52
|
const maxX = CARD_W_MM / 2 - margin;
|
|
46
53
|
const maxY = CARD_H_MM / 2 - margin;
|
|
@@ -441,8 +448,8 @@ function elementPrintability(el, ctx) {
|
|
|
441
448
|
break;
|
|
442
449
|
}
|
|
443
450
|
}
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
451
|
+
const onCard = el.type === "chip" ? discOnCard(el.transform, CHIP_RADIUS_MM * el.transform.scale) : cornersOnCard(obbCorners(el.transform, footprintOf(el).w, footprintOf(el).h));
|
|
452
|
+
if (!onCard) {
|
|
446
453
|
push("elementOffCard", "The element extends past the printable card area.");
|
|
447
454
|
}
|
|
448
455
|
return out;
|
|
@@ -643,7 +650,7 @@ function isWebAddress(value) {
|
|
|
643
650
|
}
|
|
644
651
|
var webAddressSchema = z2.string().refine(isWebAddress, "invalidUrl").optional().or(z2.literal(""));
|
|
645
652
|
function stripUnsafeChars(s) {
|
|
646
|
-
return s.replace(/[
|
|
653
|
+
return s.replace(/[\x00-\x1f\x7f<>"'`\\]/g, "").trim();
|
|
647
654
|
}
|
|
648
655
|
function sanitizeHttpUrl(raw) {
|
|
649
656
|
if (typeof raw !== "string") return "";
|
|
@@ -663,7 +670,7 @@ function sanitizeHttpUrl(raw) {
|
|
|
663
670
|
}
|
|
664
671
|
function sanitizeText(raw, max = 50) {
|
|
665
672
|
if (typeof raw !== "string") return "";
|
|
666
|
-
const noTags = raw.replace(/<[^>]*>/g, "").replace(/[
|
|
673
|
+
const noTags = raw.replace(/<[^>]*>/g, "").replace(/[\x00-\x1f\x7f]/g, "");
|
|
667
674
|
return noTags.trim().slice(0, max);
|
|
668
675
|
}
|
|
669
676
|
var secureUrl = z2.string().max(2048).optional().or(z2.literal("")).transform((v) => sanitizeHttpUrl(v));
|
|
@@ -1173,6 +1180,17 @@ function validateConfiguration(data) {
|
|
|
1173
1180
|
if (result.success) return { success: true, data: result.data };
|
|
1174
1181
|
return { success: false, errors: formatZodErrors(result.error) };
|
|
1175
1182
|
}
|
|
1183
|
+
var productTypeSchema = z2.enum(PRODUCT_TYPES);
|
|
1184
|
+
var linkDestinationTypeSchema = z2.enum(LINK_DESTINATION_TYPES);
|
|
1185
|
+
var linkStatusSchema = z2.enum(LINK_STATUSES);
|
|
1186
|
+
var linkRoleSchema = z2.enum(LINK_ROLES);
|
|
1187
|
+
var MAX_LINK_DEST_URL_LENGTH = 2048;
|
|
1188
|
+
var linkDestinationInputSchema = z2.object({
|
|
1189
|
+
destinationType: linkDestinationTypeSchema,
|
|
1190
|
+
destProfileId: z2.string().trim().min(1).max(64).optional(),
|
|
1191
|
+
destUrl: z2.string().trim().max(MAX_LINK_DEST_URL_LENGTH).optional()
|
|
1192
|
+
});
|
|
1193
|
+
var linkDestinationIntentSchema = linkDestinationInputSchema.partial();
|
|
1176
1194
|
export {
|
|
1177
1195
|
ADVANCE_FALLBACK_EM,
|
|
1178
1196
|
CARD_H_MM,
|
|
@@ -1183,6 +1201,7 @@ export {
|
|
|
1183
1201
|
EMPTY_TEXT_FLOOR_EM,
|
|
1184
1202
|
ITALIC_OVERHANG_EM,
|
|
1185
1203
|
MAX_CTA_BUTTONS,
|
|
1204
|
+
MAX_LINK_DEST_URL_LENGTH,
|
|
1186
1205
|
MIN_WALL_MM,
|
|
1187
1206
|
NFC_COIL_DIAMETER_MM,
|
|
1188
1207
|
NFC_RADIAL_CLEARANCE_MM,
|
|
@@ -1216,6 +1235,7 @@ export {
|
|
|
1216
1235
|
ctaButtonsSchema,
|
|
1217
1236
|
digitalConfigSchema,
|
|
1218
1237
|
digitalVisibilitySchema,
|
|
1238
|
+
discOnCard,
|
|
1219
1239
|
elementPrintability,
|
|
1220
1240
|
elementTransformSchema,
|
|
1221
1241
|
entrySourceSchema,
|
|
@@ -1224,12 +1244,18 @@ export {
|
|
|
1224
1244
|
hubProfilesUpdateSchema,
|
|
1225
1245
|
imageObjectStyleSchema,
|
|
1226
1246
|
isFiniteTransform,
|
|
1247
|
+
linkDestinationInputSchema,
|
|
1248
|
+
linkDestinationIntentSchema,
|
|
1249
|
+
linkDestinationTypeSchema,
|
|
1250
|
+
linkRoleSchema,
|
|
1251
|
+
linkStatusSchema,
|
|
1227
1252
|
materialSchema,
|
|
1228
1253
|
mmToNearestChipAnchor,
|
|
1229
1254
|
obbCorners,
|
|
1230
1255
|
patternFamilySchema,
|
|
1231
1256
|
physicalConfigSchema,
|
|
1232
1257
|
physicalContentOverridesSchema,
|
|
1258
|
+
productTypeSchema,
|
|
1233
1259
|
profileAccessTokenCreateSchema,
|
|
1234
1260
|
profileBackgroundSchema,
|
|
1235
1261
|
profileCreateSchema,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nfcard/validation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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.24.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"tsup": "^8.0.0"
|
package/src/booking.test.ts
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { bookingSchema, digitalConfigSchema } from './index'
|
|
3
|
-
|
|
4
|
-
const baseConfig = {
|
|
5
|
-
templateId: 'classic' as const,
|
|
6
|
-
accentColor: '#123456',
|
|
7
|
-
fontKey: 'inter',
|
|
8
|
-
showAvatar: true,
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe('bookingSchema', () => {
|
|
12
|
-
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
-
expect(bookingSchema.parse({ provider: 'salonic', handle: 'kiss-fodraszat' })).toEqual({
|
|
14
|
-
provider: 'salonic',
|
|
15
|
-
handle: 'kiss-fodraszat',
|
|
16
|
-
})
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('normalizes a pasted booking-page URL down to the subdomain handle', () => {
|
|
20
|
-
expect(
|
|
21
|
-
bookingSchema.parse({
|
|
22
|
-
provider: 'salonic',
|
|
23
|
-
handle: 'https://kiss-fodraszat.salonic.hu/booking?x=1',
|
|
24
|
-
}),
|
|
25
|
-
).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('lowercases the handle', () => {
|
|
29
|
-
expect(bookingSchema.parse({ provider: 'reservio', handle: 'HighBrow' })).toEqual({
|
|
30
|
-
provider: 'reservio',
|
|
31
|
-
handle: 'highbrow',
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
36
|
-
const out = bookingSchema.parse({
|
|
37
|
-
provider: 'salonic',
|
|
38
|
-
handle: 'kiss-fodraszat',
|
|
39
|
-
label: '<b>Foglalj</b> ' + 'x'.repeat(60),
|
|
40
|
-
})
|
|
41
|
-
expect(out?.provider).toBe('salonic')
|
|
42
|
-
expect(out?.label).not.toContain('<')
|
|
43
|
-
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
47
|
-
expect(bookingSchema.parse(undefined)).toBeUndefined()
|
|
48
|
-
expect(bookingSchema.parse(null)).toBeUndefined()
|
|
49
|
-
expect(bookingSchema.parse('nonsense')).toBeUndefined()
|
|
50
|
-
expect(bookingSchema.parse({})).toBeUndefined()
|
|
51
|
-
expect(bookingSchema.parse({ provider: 'booksy', handle: 'szalon' })).toBeUndefined()
|
|
52
|
-
expect(bookingSchema.parse({ provider: 'salonic', handle: 'www' })).toBeUndefined() // reserved
|
|
53
|
-
expect(bookingSchema.parse({ provider: 'salonic', handle: '-bad' })).toBeUndefined() // bad DNS label
|
|
54
|
-
expect(
|
|
55
|
-
bookingSchema.parse({ provider: 'salonic', handle: 'https://evil.com/szalon' }),
|
|
56
|
-
).toBeUndefined()
|
|
57
|
-
expect(
|
|
58
|
-
bookingSchema.parse({ provider: 'salonic', handle: 'javascript:alert(1)' }),
|
|
59
|
-
).toBeUndefined()
|
|
60
|
-
expect(bookingSchema.parse({ provider: 'salonic', handle: 123 })).toBeUndefined()
|
|
61
|
-
})
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
describe('digitalConfigSchema — booking field', () => {
|
|
65
|
-
it('round-trips a valid booking', () => {
|
|
66
|
-
const parsed = digitalConfigSchema.parse({
|
|
67
|
-
...baseConfig,
|
|
68
|
-
booking: { provider: 'salonic', handle: 'kiss-fodraszat' },
|
|
69
|
-
})
|
|
70
|
-
expect(parsed.booking).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('drops an invalid booking without failing the whole config save', () => {
|
|
74
|
-
const parsed = digitalConfigSchema.parse({
|
|
75
|
-
...baseConfig,
|
|
76
|
-
booking: { provider: 'salonic', handle: 'https://evil.com/x' },
|
|
77
|
-
})
|
|
78
|
-
expect(parsed.booking).toBeUndefined()
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('is optional — a config without a booking parses fine', () => {
|
|
82
|
-
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
83
|
-
expect(parsed.booking).toBeUndefined()
|
|
84
|
-
})
|
|
85
|
-
})
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { bookingSchema, digitalConfigSchema } from './index'
|
|
3
|
+
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
templateId: 'classic' as const,
|
|
6
|
+
accentColor: '#123456',
|
|
7
|
+
fontKey: 'inter',
|
|
8
|
+
showAvatar: true,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('bookingSchema', () => {
|
|
12
|
+
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 'kiss-fodraszat' })).toEqual({
|
|
14
|
+
provider: 'salonic',
|
|
15
|
+
handle: 'kiss-fodraszat',
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('normalizes a pasted booking-page URL down to the subdomain handle', () => {
|
|
20
|
+
expect(
|
|
21
|
+
bookingSchema.parse({
|
|
22
|
+
provider: 'salonic',
|
|
23
|
+
handle: 'https://kiss-fodraszat.salonic.hu/booking?x=1',
|
|
24
|
+
}),
|
|
25
|
+
).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('lowercases the handle', () => {
|
|
29
|
+
expect(bookingSchema.parse({ provider: 'reservio', handle: 'HighBrow' })).toEqual({
|
|
30
|
+
provider: 'reservio',
|
|
31
|
+
handle: 'highbrow',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
36
|
+
const out = bookingSchema.parse({
|
|
37
|
+
provider: 'salonic',
|
|
38
|
+
handle: 'kiss-fodraszat',
|
|
39
|
+
label: '<b>Foglalj</b> ' + 'x'.repeat(60),
|
|
40
|
+
})
|
|
41
|
+
expect(out?.provider).toBe('salonic')
|
|
42
|
+
expect(out?.label).not.toContain('<')
|
|
43
|
+
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
47
|
+
expect(bookingSchema.parse(undefined)).toBeUndefined()
|
|
48
|
+
expect(bookingSchema.parse(null)).toBeUndefined()
|
|
49
|
+
expect(bookingSchema.parse('nonsense')).toBeUndefined()
|
|
50
|
+
expect(bookingSchema.parse({})).toBeUndefined()
|
|
51
|
+
expect(bookingSchema.parse({ provider: 'booksy', handle: 'szalon' })).toBeUndefined()
|
|
52
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 'www' })).toBeUndefined() // reserved
|
|
53
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: '-bad' })).toBeUndefined() // bad DNS label
|
|
54
|
+
expect(
|
|
55
|
+
bookingSchema.parse({ provider: 'salonic', handle: 'https://evil.com/szalon' }),
|
|
56
|
+
).toBeUndefined()
|
|
57
|
+
expect(
|
|
58
|
+
bookingSchema.parse({ provider: 'salonic', handle: 'javascript:alert(1)' }),
|
|
59
|
+
).toBeUndefined()
|
|
60
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 123 })).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('digitalConfigSchema — booking field', () => {
|
|
65
|
+
it('round-trips a valid booking', () => {
|
|
66
|
+
const parsed = digitalConfigSchema.parse({
|
|
67
|
+
...baseConfig,
|
|
68
|
+
booking: { provider: 'salonic', handle: 'kiss-fodraszat' },
|
|
69
|
+
})
|
|
70
|
+
expect(parsed.booking).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('drops an invalid booking without failing the whole config save', () => {
|
|
74
|
+
const parsed = digitalConfigSchema.parse({
|
|
75
|
+
...baseConfig,
|
|
76
|
+
booking: { provider: 'salonic', handle: 'https://evil.com/x' },
|
|
77
|
+
})
|
|
78
|
+
expect(parsed.booking).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('is optional — a config without a booking parses fine', () => {
|
|
82
|
+
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
83
|
+
expect(parsed.booking).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { sanitizeHttpUrl, sanitizeText } from './index';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The character classes in `stripUnsafeChars` and `sanitizeText` used to be
|
|
9
|
+
* written with LITERAL 0x00 / 0x1f / 0x7f bytes in the source.
|
|
10
|
+
*
|
|
11
|
+
* That was not a style problem. It made this file read as BINARY to ripgrep
|
|
12
|
+
* ("found \0 byte around offset 2873"), so `grep`/`Grep` silently skipped it
|
|
13
|
+
* and anyone searching for `sanitizeHttpUrl` concluded it did not exist. Worse,
|
|
14
|
+
* a Prettier run, an editor "strip control characters" save, or a
|
|
15
|
+
* `.gitattributes` filter would rewrite those classes without anyone noticing —
|
|
16
|
+
* and `sanitizeHttpUrl` is the SECOND-OPINION gate that `parseExternalUrl` in
|
|
17
|
+
* nfcard-api cross-checks itself against. The two disagreeing turns into a
|
|
18
|
+
* blanket refusal of every external URL: an outage, from a formatter.
|
|
19
|
+
*
|
|
20
|
+
* The bytes are now `\x00` / `\x1f` / `\x7f` escapes — same semantics, plain
|
|
21
|
+
* ASCII, greppable, formatter-proof. These tests keep it that way.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const SOURCE = fs.readFileSync(path.join(HERE, 'index.ts'));
|
|
26
|
+
|
|
27
|
+
describe('the source file stays plain ASCII', () => {
|
|
28
|
+
it('contains NO literal control bytes — that is what made it read as binary', () => {
|
|
29
|
+
const offenders: string[] = [];
|
|
30
|
+
for (let i = 0; i < SOURCE.length; i++) {
|
|
31
|
+
const byte = SOURCE[i];
|
|
32
|
+
const isControl = byte === 0 || byte < 9 || (byte > 13 && byte < 32) || byte === 127;
|
|
33
|
+
if (isControl) offenders.push(`0x${byte.toString(16)} @ ${i}`);
|
|
34
|
+
}
|
|
35
|
+
expect(offenders).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('still declares the classes it always did, as escapes', () => {
|
|
39
|
+
const text = SOURCE.toString('utf8');
|
|
40
|
+
// ⚠️ Not a cosmetic assertion. `[ -<...]` — which is what these look like
|
|
41
|
+
// once the control bytes are eaten — is a RANGE over U+0020–U+003C that
|
|
42
|
+
// swallows `.` `/` `:` and every digit. That exact bug is on the record in
|
|
43
|
+
// this codebase; this pins the intended form so it cannot come back.
|
|
44
|
+
expect(text).toContain('/[\\x00-\\x1f\\x7f<>"\'`\\\\]/g');
|
|
45
|
+
expect(text).toContain('/[\\x00-\\x1f\\x7f]/g');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('the classes behave exactly as before the escape rewrite', () => {
|
|
50
|
+
const CONTROLS = ['\x00', '\x01', '\x1f', '\x7f'];
|
|
51
|
+
|
|
52
|
+
it.each(CONTROLS)('sanitizeHttpUrl strips %j out of a host', (ch) => {
|
|
53
|
+
expect(sanitizeHttpUrl(`https://exam${ch}ple.com/x`)).toBe('https://example.com/x');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each(CONTROLS)('sanitizeText strips %j', (ch) => {
|
|
57
|
+
expect(sanitizeText(`Ri${ch}csi`)).toBe('Ricsi');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('still strips the HTML-ish characters beside them', () => {
|
|
61
|
+
expect(sanitizeHttpUrl('https://example.com/<script>')).toBe('https://example.com/script');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does NOT strip the printable characters a URL needs', () => {
|
|
65
|
+
// The guard against re-introducing the U+0020–U+003C range bug: every one
|
|
66
|
+
// of these sits inside that range and must survive.
|
|
67
|
+
expect(sanitizeHttpUrl('https://example.com/a.b/c-1?d=2&e=3')).toBe(
|
|
68
|
+
'https://example.com/a.b/c-1?d=2&e=3',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/index.ts
CHANGED
|
Binary file
|
package/src/link.test.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { LINK_DESTINATION_TYPES, PRODUCT_TYPES } from '@nfcard/types'
|
|
3
|
+
import {
|
|
4
|
+
MAX_LINK_DEST_URL_LENGTH,
|
|
5
|
+
linkDestinationIntentSchema,
|
|
6
|
+
linkDestinationTypeSchema,
|
|
7
|
+
linkRoleSchema,
|
|
8
|
+
linkStatusSchema,
|
|
9
|
+
productTypeSchema,
|
|
10
|
+
} from './index'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* NFCARD-330 — the Zod half of the Link contracts.
|
|
14
|
+
*
|
|
15
|
+
* The assertion that matters most is the DERIVATION one: these schemas are
|
|
16
|
+
* built from the `@nfcard/types` const arrays rather than re-spelling the
|
|
17
|
+
* strings, which is a deliberate departure from this package's house style and
|
|
18
|
+
* the whole reason the ticket exists.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
describe('the enum schemas', () => {
|
|
22
|
+
it('accept exactly the vocabulary and nothing else', () => {
|
|
23
|
+
for (const type of PRODUCT_TYPES) {
|
|
24
|
+
expect(productTypeSchema.safeParse(type).success).toBe(true)
|
|
25
|
+
}
|
|
26
|
+
expect(productTypeSchema.safeParse('poster').success).toBe(false)
|
|
27
|
+
|
|
28
|
+
for (const type of LINK_DESTINATION_TYPES) {
|
|
29
|
+
expect(linkDestinationTypeSchema.safeParse(type).success).toBe(true)
|
|
30
|
+
}
|
|
31
|
+
expect(linkDestinationTypeSchema.safeParse('hub').success).toBe(false)
|
|
32
|
+
|
|
33
|
+
expect(linkStatusSchema.safeParse('minted').success).toBe(true)
|
|
34
|
+
expect(linkStatusSchema.safeParse('dormant').success).toBe(false)
|
|
35
|
+
expect(linkRoleSchema.safeParse('qr').success).toBe(true)
|
|
36
|
+
expect(linkRoleSchema.safeParse('sticker').success).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// ⭐ THE DERIVATION. If someone "helpfully" rewrites these as literal
|
|
40
|
+
// z.enum(['card', …]) lists, the schema and the type can drift — which is the
|
|
41
|
+
// exact failure NFCARD-330 was filed to end. This asserts the two are the
|
|
42
|
+
// same set, so a one-sided edit fails here.
|
|
43
|
+
it('stay derived from the types package, not hand-copied', () => {
|
|
44
|
+
expect(productTypeSchema.options).toEqual([...PRODUCT_TYPES])
|
|
45
|
+
expect(linkDestinationTypeSchema.options).toEqual([...LINK_DESTINATION_TYPES])
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('linkDestinationInputSchema / intent', () => {
|
|
50
|
+
it('accepts a destination with its matching target', () => {
|
|
51
|
+
expect(
|
|
52
|
+
linkDestinationIntentSchema.safeParse({ destinationType: 'profile', destProfileId: 'p1' })
|
|
53
|
+
.success,
|
|
54
|
+
).toBe(true)
|
|
55
|
+
expect(
|
|
56
|
+
linkDestinationIntentSchema.safeParse({
|
|
57
|
+
destinationType: 'external',
|
|
58
|
+
destUrl: 'https://barberbros.hu/foglalas',
|
|
59
|
+
}).success,
|
|
60
|
+
).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// "The buyer never opened the picker" is the overwhelmingly common state and
|
|
64
|
+
// has to be storable as silence, not as an explicit default.
|
|
65
|
+
it('accepts an entirely empty intent', () => {
|
|
66
|
+
expect(linkDestinationIntentSchema.safeParse({}).success).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('refuses a destination type that does not exist', () => {
|
|
70
|
+
expect(linkDestinationIntentSchema.safeParse({ destinationType: 'hub' }).success).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Capped, but NOT URL-validated: the one gate is the server's external-URL
|
|
74
|
+
// parser, and a second looser check here would teach a client that a URL the
|
|
75
|
+
// server will refuse was accepted.
|
|
76
|
+
it('caps the URL length without judging the URL', () => {
|
|
77
|
+
expect(
|
|
78
|
+
linkDestinationIntentSchema.safeParse({ destinationType: 'external', destUrl: 'not-a-url' })
|
|
79
|
+
.success,
|
|
80
|
+
).toBe(true)
|
|
81
|
+
expect(
|
|
82
|
+
linkDestinationIntentSchema.safeParse({
|
|
83
|
+
destinationType: 'external',
|
|
84
|
+
destUrl: 'h'.repeat(MAX_LINK_DEST_URL_LENGTH + 1),
|
|
85
|
+
}).success,
|
|
86
|
+
).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('trims the targets so a padded value is stored clean', () => {
|
|
90
|
+
const parsed = linkDestinationIntentSchema.parse({
|
|
91
|
+
destinationType: 'profile',
|
|
92
|
+
destProfileId: ' p1 ',
|
|
93
|
+
})
|
|
94
|
+
expect(parsed.destProfileId).toBe('p1')
|
|
95
|
+
})
|
|
96
|
+
})
|
package/src/parity.test.ts
CHANGED
|
@@ -26,14 +26,33 @@ import {
|
|
|
26
26
|
* Regenerate (both sides together):
|
|
27
27
|
* node ../../nfcard-cardgen/tests/parity/gen_printability_parity.mjs
|
|
28
28
|
*
|
|
29
|
-
* Skips cleanly if nfcard-cardgen isn't checked out alongside (shared-only CI)
|
|
29
|
+
* Skips cleanly if nfcard-cardgen isn't checked out alongside (shared-only CI) — but says so
|
|
30
|
+
* out loud. A silent skip is the worst outcome here: the guard reads as green while the two
|
|
31
|
+
* implementations drift. Set `NFCARD_CARDGEN_DIR` when the sibling layout does not apply (a
|
|
32
|
+
* git worktree sits two levels deeper, so the relative guess misses).
|
|
30
33
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
34
|
+
function resolveGolden(): string | null {
|
|
35
|
+
const rel = 'tests/parity/printability_parity.json'
|
|
36
|
+
const env = process.env.NFCARD_CARDGEN_DIR
|
|
37
|
+
const candidates = env
|
|
38
|
+
? [`${env.replace(/[\\/]+$/, '')}/${rel}`]
|
|
39
|
+
: [
|
|
40
|
+
fileURLToPath(new URL(`../../../nfcard-cardgen/${rel}`, import.meta.url)),
|
|
41
|
+
fileURLToPath(new URL(`../../../../../nfcard-cardgen/${rel}`, import.meta.url)),
|
|
42
|
+
]
|
|
43
|
+
return candidates.find((p) => existsSync(p)) ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const GOLDEN = resolveGolden()
|
|
47
|
+
const present = GOLDEN !== null
|
|
48
|
+
if (!present) {
|
|
49
|
+
console.warn(
|
|
50
|
+
'[parity] SKIPPED — nfcard-cardgen/tests/parity/printability_parity.json not found. ' +
|
|
51
|
+
'Set NFCARD_CARDGEN_DIR to run the cross-language guard.',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
35
54
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
-
const golden: any =
|
|
55
|
+
const golden: any = GOLDEN ? JSON.parse(readFileSync(GOLDEN, 'utf-8')) : { elementCases: [] }
|
|
37
56
|
const TOL = 1e-9
|
|
38
57
|
|
|
39
58
|
// Mirror cardgen `validate_elements`: per-element printability + the graph-level
|
package/src/printability.test.ts
CHANGED
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
ITALIC_OVERHANG_EM,
|
|
6
6
|
TEXT_LINE_FACTOR_EM,
|
|
7
7
|
elementPrintability,
|
|
8
|
+
discOnCard,
|
|
9
|
+
CHIP_RADIUS_MM,
|
|
10
|
+
chipPlacementMaxXY,
|
|
8
11
|
footprintOf,
|
|
9
12
|
textAdvanceEm,
|
|
10
13
|
CARD_W_MM,
|
|
@@ -105,3 +108,47 @@ describe('NFCARD-530 — elementOffCard verdict with the accurate estimate', ()
|
|
|
105
108
|
expect(elementPrintability(el, ctx).map((v) => v.code)).toContain('elementOffCard')
|
|
106
109
|
})
|
|
107
110
|
})
|
|
111
|
+
|
|
112
|
+
// ── NFCARD-841 — the chip is a DISC, so its on-card verdict is rotation-invariant ──
|
|
113
|
+
describe('chip on-card check (NFCARD-841)', () => {
|
|
114
|
+
const chip = (xMm: number, yMm: number, rotationDeg = 0, scale = 1): CardElement =>
|
|
115
|
+
({ id: 'c1', type: 'chip', shape: 0, transform: { xMm, yMm, rotationDeg, scale, z: 0 } }) as unknown as CardElement
|
|
116
|
+
const codes = (el: CardElement) => elementPrintability(el, { assetsById: new Map() }).map((v) => v.code)
|
|
117
|
+
|
|
118
|
+
it('rotation NEVER changes a chip verdict — it used to, at every angle past scale 1.31', () => {
|
|
119
|
+
for (const scale of [1, 1.2, 1.4, 1.6]) {
|
|
120
|
+
for (const [x, y] of [[0, 0], [10, 2], [-18, -2], [26.55, 2.5]] as const) {
|
|
121
|
+
const at0 = codes(chip(x, y, 0, scale))
|
|
122
|
+
for (const rot of [15, 30, 45, 60, 90, 180, 275]) {
|
|
123
|
+
expect(codes(chip(x, y, rot, scale)), `x${x} y${y} s${scale} rot${rot}`).toEqual(at0)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('the case that had NO legal position: 45° at scale 1.6, anywhere on the card', () => {
|
|
130
|
+
// Before the fix the square's half-diagonal (13.75·1.6·√2 = 31.1 mm) exceeded the 25.5 mm
|
|
131
|
+
// rim, so elementOffCard fired at EVERY centre — a design no placement could rescue.
|
|
132
|
+
expect(codes(chip(0, 0, 45, 1.6))).toEqual([])
|
|
133
|
+
expect(codes(chip(0, 0, 0, 1.6))).toEqual([]) // …and it always agreed with the unrotated twin
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('at rotation 0 the new bound is EXACTLY the old one — no design changes verdict', () => {
|
|
137
|
+
// The square's half-height IS the disc radius, so this is a pure no-op where it used to be
|
|
138
|
+
// correct. Walk the y axis across the boundary and check the flip lands on the same value.
|
|
139
|
+
const rimMaxY = 54 / 2 - 1.5 - CHIP_RADIUS_MM // the old square bound at scale 1 = 11.75
|
|
140
|
+
expect(discOnCard(chip(0, rimMaxY).transform, CHIP_RADIUS_MM)).toBe(true)
|
|
141
|
+
expect(discOnCard(chip(0, rimMaxY + 0.01).transform, CHIP_RADIUS_MM)).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('the ENVELOPE still binds first, so this did not loosen the pocket rule', () => {
|
|
145
|
+
// chipPlacementMaxXY is the disc bound minus the 1 mm pocket buffer, at every scale — so a
|
|
146
|
+
// chip that clears the envelope can never trip elementOffCard, and NFCARD-421 stays fixed.
|
|
147
|
+
for (const scale of [1, 1.3, 1.6]) {
|
|
148
|
+
const env = chipPlacementMaxXY(scale)
|
|
149
|
+
expect(env.maxY).toBeCloseTo(54 / 2 - 1.5 - CHIP_RADIUS_MM * scale - 1, 6)
|
|
150
|
+
expect(codes(chip(0, env.maxY, 45, scale))).toEqual([]) // on the envelope edge: legal
|
|
151
|
+
expect(codes(chip(0, env.maxY + 0.02, 45, scale))).toEqual(['chipOutsideEnvelope']) // just past: ONLY the envelope
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
})
|
package/src/printability.ts
CHANGED
|
@@ -86,6 +86,27 @@ export function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[]
|
|
|
86
86
|
return local.map(([x, y]) => ({ x: t.xMm + x * c - y * s, y: t.yMm + x * s + y * c }))
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Is a DISC of radius `rMm` centred at `t` inside the ID-1 rim minus the safety margin?
|
|
90
|
+
*
|
|
91
|
+
* NFCARD-841 — the chip is physically a DISC, but {@link footprintOf} gives it the 27.5 mm
|
|
92
|
+
* bounding SQUARE, so running it through {@link cornersOnCard} + {@link obbCorners} made its
|
|
93
|
+
* on-card verdict ROTATION-SENSITIVE. A circle covers the same area at every angle, so that is
|
|
94
|
+
* wrong in principle; it was also wrong in practice, because past scale ≈1.31 the square's
|
|
95
|
+
* half-diagonal (13.75·s·√2) exceeds the 25.5 mm rim and a 45°-rotated chip was rejected at
|
|
96
|
+
* EVERY position on the card — unorderable with no placement that could save it.
|
|
97
|
+
*
|
|
98
|
+
* ⚠ At rotation 0 this is EXACTLY the old bound (the square's half-height IS the radius), so no
|
|
99
|
+
* previously-legal design changes verdict and no previously-illegal one is let through. The only
|
|
100
|
+
* behaviour that moves is the rotation artifact.
|
|
101
|
+
*
|
|
102
|
+
* Rotation-invariant by construction — it takes no angle. Mirrors cardgen `disc_on_card`. */
|
|
103
|
+
export function discOnCard(t: ElementTransform, rMm: number, margin = EDGE_MARGIN_MM): boolean {
|
|
104
|
+
return (
|
|
105
|
+
Math.abs(t.xMm) + rMm <= CARD_W_MM / 2 - margin + EPS &&
|
|
106
|
+
Math.abs(t.yMm) + rMm <= CARD_H_MM / 2 - margin + EPS
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
/** Are all corners inside the ID-1 rim minus the safety margin? */
|
|
90
111
|
export function cornersOnCard(corners: Pt[], margin = EDGE_MARGIN_MM): boolean {
|
|
91
112
|
const maxX = CARD_W_MM / 2 - margin
|
|
@@ -323,8 +344,14 @@ export function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Viol
|
|
|
323
344
|
}
|
|
324
345
|
}
|
|
325
346
|
|
|
326
|
-
|
|
327
|
-
|
|
347
|
+
// NFCARD-841 — the CHIP is judged as the disc it is, not as its bounding square, so the
|
|
348
|
+
// verdict cannot depend on an angle a circle does not have. Every other element keeps the OBB
|
|
349
|
+
// check: their footprints really are rectangles and rotation really does move their corners.
|
|
350
|
+
const onCard =
|
|
351
|
+
el.type === 'chip'
|
|
352
|
+
? discOnCard(el.transform, CHIP_RADIUS_MM * el.transform.scale)
|
|
353
|
+
: cornersOnCard(obbCorners(el.transform, footprintOf(el).w, footprintOf(el).h))
|
|
354
|
+
if (!onCard) {
|
|
328
355
|
push('elementOffCard', 'The element extends past the printable card area.')
|
|
329
356
|
}
|
|
330
357
|
return out
|
package/src/tipJar.test.ts
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { tipJarSchema, digitalConfigSchema } from './index'
|
|
3
|
-
|
|
4
|
-
const baseConfig = {
|
|
5
|
-
templateId: 'classic' as const,
|
|
6
|
-
accentColor: '#123456',
|
|
7
|
-
fontKey: 'inter',
|
|
8
|
-
showAvatar: true,
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe('tipJarSchema', () => {
|
|
12
|
-
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
|
|
14
|
-
provider: 'kofi',
|
|
15
|
-
handle: 'ricsi',
|
|
16
|
-
})
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('normalizes a pasted full URL down to the handle', () => {
|
|
20
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
|
|
21
|
-
{ provider: 'kofi', handle: 'ricsi' },
|
|
22
|
-
)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('lowercases revolut handles', () => {
|
|
26
|
-
expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
|
|
27
|
-
provider: 'revolut',
|
|
28
|
-
handle: 'ricsi',
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
33
|
-
const out = tipJarSchema.parse({
|
|
34
|
-
provider: 'kofi',
|
|
35
|
-
handle: 'ricsi',
|
|
36
|
-
label: '<b>Támogass</b> ' + 'x'.repeat(60),
|
|
37
|
-
})
|
|
38
|
-
expect(out?.provider).toBe('kofi')
|
|
39
|
-
expect(out?.label).not.toContain('<')
|
|
40
|
-
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
44
|
-
expect(tipJarSchema.parse(undefined)).toBeUndefined()
|
|
45
|
-
expect(tipJarSchema.parse(null)).toBeUndefined()
|
|
46
|
-
expect(tipJarSchema.parse('nonsense')).toBeUndefined()
|
|
47
|
-
expect(tipJarSchema.parse({})).toBeUndefined()
|
|
48
|
-
expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
|
|
49
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
|
|
50
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
|
|
51
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
|
|
52
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe('digitalConfigSchema — tipJar field', () => {
|
|
57
|
-
it('round-trips a valid tipJar', () => {
|
|
58
|
-
const parsed = digitalConfigSchema.parse({
|
|
59
|
-
...baseConfig,
|
|
60
|
-
tipJar: { provider: 'revolut', handle: 'ricsi' },
|
|
61
|
-
})
|
|
62
|
-
expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('drops an invalid tipJar without failing the whole config save', () => {
|
|
66
|
-
const parsed = digitalConfigSchema.parse({
|
|
67
|
-
...baseConfig,
|
|
68
|
-
tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
|
|
69
|
-
})
|
|
70
|
-
expect(parsed.tipJar).toBeUndefined()
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('is optional — a config without a tipJar parses fine', () => {
|
|
74
|
-
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
75
|
-
expect(parsed.tipJar).toBeUndefined()
|
|
76
|
-
})
|
|
77
|
-
})
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { tipJarSchema, digitalConfigSchema } from './index'
|
|
3
|
+
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
templateId: 'classic' as const,
|
|
6
|
+
accentColor: '#123456',
|
|
7
|
+
fontKey: 'inter',
|
|
8
|
+
showAvatar: true,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('tipJarSchema', () => {
|
|
12
|
+
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
|
|
14
|
+
provider: 'kofi',
|
|
15
|
+
handle: 'ricsi',
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('normalizes a pasted full URL down to the handle', () => {
|
|
20
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
|
|
21
|
+
{ provider: 'kofi', handle: 'ricsi' },
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('lowercases revolut handles', () => {
|
|
26
|
+
expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
|
|
27
|
+
provider: 'revolut',
|
|
28
|
+
handle: 'ricsi',
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
33
|
+
const out = tipJarSchema.parse({
|
|
34
|
+
provider: 'kofi',
|
|
35
|
+
handle: 'ricsi',
|
|
36
|
+
label: '<b>Támogass</b> ' + 'x'.repeat(60),
|
|
37
|
+
})
|
|
38
|
+
expect(out?.provider).toBe('kofi')
|
|
39
|
+
expect(out?.label).not.toContain('<')
|
|
40
|
+
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
44
|
+
expect(tipJarSchema.parse(undefined)).toBeUndefined()
|
|
45
|
+
expect(tipJarSchema.parse(null)).toBeUndefined()
|
|
46
|
+
expect(tipJarSchema.parse('nonsense')).toBeUndefined()
|
|
47
|
+
expect(tipJarSchema.parse({})).toBeUndefined()
|
|
48
|
+
expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
|
|
49
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
|
|
50
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
|
|
51
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
|
|
52
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('digitalConfigSchema — tipJar field', () => {
|
|
57
|
+
it('round-trips a valid tipJar', () => {
|
|
58
|
+
const parsed = digitalConfigSchema.parse({
|
|
59
|
+
...baseConfig,
|
|
60
|
+
tipJar: { provider: 'revolut', handle: 'ricsi' },
|
|
61
|
+
})
|
|
62
|
+
expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('drops an invalid tipJar without failing the whole config save', () => {
|
|
66
|
+
const parsed = digitalConfigSchema.parse({
|
|
67
|
+
...baseConfig,
|
|
68
|
+
tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
|
|
69
|
+
})
|
|
70
|
+
expect(parsed.tipJar).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('is optional — a config without a tipJar parses fine', () => {
|
|
74
|
+
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
75
|
+
expect(parsed.tipJar).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|