@hy_ong/zod-kit 0.2.2 → 0.2.3
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/{chunk-JXY7APBU.js → chunk-2JGRV3JO.js} +1 -1
- package/dist/{chunk-FM3EZ72O.js → chunk-3QLUXIY2.js} +1 -1
- package/dist/chunk-3ZF5JO3F.js +61 -0
- package/dist/{chunk-STNHTRG7.cjs → chunk-4AQB4RSU.cjs} +3 -3
- package/dist/{chunk-OPQJWHXN.cjs → chunk-53EEWALQ.cjs} +3 -3
- package/dist/{chunk-ZNJLWJX3.cjs → chunk-5ZMTAI4G.cjs} +3 -3
- package/dist/{chunk-RKUQREMW.js → chunk-6DFP7XY2.js} +1 -1
- package/dist/{chunk-5LEXCVLX.js → chunk-6IAPM7BP.js} +1 -1
- package/dist/{chunk-RO47DKQG.js → chunk-7SKH66CM.js} +1 -1
- package/dist/chunk-7X3XPK6A.js +92 -0
- package/dist/{chunk-UBK3VCVH.cjs → chunk-A2GAEU4O.cjs} +3 -3
- package/dist/{chunk-KFOHKTFD.js → chunk-AI4U42JV.js} +1 -1
- package/dist/{chunk-ERH4NIMU.cjs → chunk-AYCXAJRA.cjs} +3 -3
- package/dist/{chunk-YDH3L27K.cjs → chunk-BZSPJJYT.cjs} +3 -3
- package/dist/chunk-BZWQPSHO.cjs +92 -0
- package/dist/{chunk-H2XTEM4M.js → chunk-D55YFP5R.js} +1 -1
- package/dist/{chunk-LL4ZWLGO.js → chunk-E52GRXYY.js} +1 -1
- package/dist/{chunk-RRPXIRTQ.cjs → chunk-EDTNS2XL.cjs} +3 -3
- package/dist/{chunk-RYFG2GKM.cjs → chunk-FEL432I2.cjs} +3 -3
- package/dist/chunk-FMPHW7ID.cjs +61 -0
- package/dist/{chunk-JOLSGZGN.cjs → chunk-FP4O2ICM.cjs} +3 -3
- package/dist/{chunk-EGHL277K.cjs → chunk-G747FHUZ.cjs} +3 -3
- package/dist/{chunk-5JGTDL3Y.js → chunk-G7FLGJYD.js} +1 -1
- package/dist/{chunk-HMSM6FFA.cjs → chunk-H25N5GP6.cjs} +3 -3
- package/dist/{chunk-MHJFYYGV.js → chunk-H6STFX4I.js} +1 -1
- package/dist/{chunk-EDHT4LPO.js → chunk-HQ4RAMSD.js} +1 -1
- package/dist/{chunk-ORFHDJII.cjs → chunk-HZ2WESSL.cjs} +3 -3
- package/dist/{chunk-ORVV4MCF.cjs → chunk-IWR3H7IH.cjs} +3 -3
- package/dist/{chunk-B4EZYZOK.cjs → chunk-JZEF5Q3W.cjs} +3 -3
- package/dist/{chunk-HTEHINI7.cjs → chunk-KIUO2HIR.cjs} +3 -3
- package/dist/{chunk-QICQ6YEY.js → chunk-LBH5U2DZ.js} +1 -1
- package/dist/{chunk-42C5OHRK.js → chunk-LC4RNKBM.js} +1 -1
- package/dist/{chunk-TFGS34VD.cjs → chunk-LNWEJED7.cjs} +3 -3
- package/dist/{chunk-32JI34CV.cjs → chunk-LXFRQLH4.cjs} +3 -3
- package/dist/{chunk-46VAH2BJ.js → chunk-NWQSOSNF.js} +1 -1
- package/dist/{chunk-WDI4QJMQ.js → chunk-OGU7AIZF.js} +1 -1
- package/dist/{chunk-YIM3D2AD.js → chunk-OKO6WO6M.js} +1 -1
- package/dist/{chunk-MINMXGW3.js → chunk-OSUPJCBA.js} +1 -1
- package/dist/{chunk-6AAP4LPF.js → chunk-POIDES2L.js} +110 -0
- package/dist/{chunk-YPSEIDUR.cjs → chunk-Q24GYUTO.cjs} +3 -3
- package/dist/{chunk-UCOXAZJF.cjs → chunk-Q7TUNJD4.cjs} +110 -0
- package/dist/{chunk-L4HSIKTU.cjs → chunk-QQWX3ICK.cjs} +3 -3
- package/dist/{chunk-COYKBWTI.js → chunk-RFWCYULE.js} +1 -1
- package/dist/{chunk-ZXUMK2RR.js → chunk-RHKBT3M2.js} +1 -1
- package/dist/{chunk-LH7ZB4BK.js → chunk-RVGCMQ4J.js} +1 -1
- package/dist/{chunk-K2UOY6TB.js → chunk-T7PG4JDW.js} +1 -1
- package/dist/{chunk-DFJZ3NS2.cjs → chunk-TDEXEIHH.cjs} +3 -3
- package/dist/{chunk-GJIRDBZJ.cjs → chunk-TPXRQT2H.cjs} +3 -3
- package/dist/{chunk-UQZKFAFX.js → chunk-TRQMRHFM.js} +1 -1
- package/dist/{chunk-WABKPFPK.js → chunk-U2PB6XEO.js} +1 -1
- package/dist/{chunk-ZTFCJCPO.cjs → chunk-UCPKW43K.cjs} +3 -3
- package/dist/{chunk-TQXDUMML.cjs → chunk-V2KKGSKQ.cjs} +3 -3
- package/dist/{chunk-VB2KV2ZM.cjs → chunk-VKBNKPFO.cjs} +3 -3
- package/dist/{chunk-MM7IL2RG.js → chunk-XAN4CAVH.js} +1 -1
- package/dist/{chunk-M6MTP3NY.cjs → chunk-ZCX22PY4.cjs} +3 -3
- package/dist/{chunk-2SWEVDFZ.js → chunk-ZXPRRNZR.js} +1 -1
- package/dist/common/boolean.cjs +3 -3
- package/dist/common/boolean.js +2 -2
- package/dist/common/color.cjs +3 -3
- package/dist/common/color.js +2 -2
- package/dist/common/coordinate.cjs +3 -3
- package/dist/common/coordinate.js +2 -2
- package/dist/common/credit-card.cjs +3 -3
- package/dist/common/credit-card.js +2 -2
- package/dist/common/date.cjs +3 -3
- package/dist/common/date.js +2 -2
- package/dist/common/datetime.cjs +3 -3
- package/dist/common/datetime.js +2 -2
- package/dist/common/email.cjs +3 -3
- package/dist/common/email.js +2 -2
- package/dist/common/file.cjs +3 -3
- package/dist/common/file.js +2 -2
- package/dist/common/id.cjs +3 -3
- package/dist/common/id.js +2 -2
- package/dist/common/ip.cjs +3 -3
- package/dist/common/ip.js +2 -2
- package/dist/common/many-of.cjs +7 -0
- package/dist/common/many-of.d.cts +111 -0
- package/dist/common/many-of.d.ts +111 -0
- package/dist/common/many-of.js +7 -0
- package/dist/common/number.cjs +3 -3
- package/dist/common/number.js +2 -2
- package/dist/common/one-of.cjs +7 -0
- package/dist/common/one-of.d.cts +104 -0
- package/dist/common/one-of.d.ts +104 -0
- package/dist/common/one-of.js +7 -0
- package/dist/common/password.cjs +3 -3
- package/dist/common/password.js +2 -2
- package/dist/common/text.cjs +3 -3
- package/dist/common/text.js +2 -2
- package/dist/common/time.cjs +3 -3
- package/dist/common/time.js +2 -2
- package/dist/common/url.cjs +3 -3
- package/dist/common/url.js +2 -2
- package/dist/index.cjs +35 -27
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +46 -38
- package/dist/taiwan/bank-account.cjs +3 -3
- package/dist/taiwan/bank-account.js +2 -2
- package/dist/taiwan/business-id.cjs +3 -3
- package/dist/taiwan/business-id.js +2 -2
- package/dist/taiwan/fax.cjs +3 -3
- package/dist/taiwan/fax.js +2 -2
- package/dist/taiwan/invoice.cjs +3 -3
- package/dist/taiwan/invoice.js +2 -2
- package/dist/taiwan/license-plate.cjs +3 -3
- package/dist/taiwan/license-plate.js +2 -2
- package/dist/taiwan/mobile.cjs +3 -3
- package/dist/taiwan/mobile.js +2 -2
- package/dist/taiwan/national-id.cjs +3 -3
- package/dist/taiwan/national-id.js +2 -2
- package/dist/taiwan/passport.cjs +3 -3
- package/dist/taiwan/passport.js +2 -2
- package/dist/taiwan/postal-code.cjs +3 -3
- package/dist/taiwan/postal-code.js +2 -2
- package/dist/taiwan/tel.cjs +3 -3
- package/dist/taiwan/tel.js +2 -2
- package/package.json +11 -1
- package/src/i18n/locales/en-GB.json +11 -0
- package/src/i18n/locales/en-US.json +11 -0
- package/src/i18n/locales/id-ID.json +11 -0
- package/src/i18n/locales/ja-JP.json +11 -0
- package/src/i18n/locales/ko-KR.json +11 -0
- package/src/i18n/locales/ms-MY.json +11 -0
- package/src/i18n/locales/th-TH.json +11 -0
- package/src/i18n/locales/vi-VN.json +11 -0
- package/src/i18n/locales/zh-CN.json +11 -0
- package/src/i18n/locales/zh-TW.json +11 -0
- package/src/index.ts +2 -0
- package/src/validators/common/many-of.ts +219 -0
- package/src/validators/common/one-of.ts +172 -0
- package/tests/common/many-of.test.ts +198 -0
- package/tests/common/one-of.test.ts +136 -0
- package/tsup.config.ts +2 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview OneOf validator for Zod Kit
|
|
3
|
+
*
|
|
4
|
+
* Provides single-select validation that restricts input to a predefined set of allowed values,
|
|
5
|
+
* with support for case-insensitive matching, default values, and transformation.
|
|
6
|
+
*
|
|
7
|
+
* @author Ong Hoe Yuan
|
|
8
|
+
* @version 0.2.2
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z, ZodType } from "zod"
|
|
12
|
+
import { t } from "../../i18n"
|
|
13
|
+
import { getLocale, type Locale } from "../../config"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type definition for oneOf validation error messages
|
|
17
|
+
*
|
|
18
|
+
* @interface OneOfMessages
|
|
19
|
+
* @property {string} [required] - Message when field is required but empty
|
|
20
|
+
* @property {string} [invalid] - Message when value is not in the allowed list
|
|
21
|
+
*/
|
|
22
|
+
export type OneOfMessages = {
|
|
23
|
+
required?: string
|
|
24
|
+
invalid?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration options for oneOf validation
|
|
29
|
+
*
|
|
30
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
31
|
+
* @template T - The type of allowed values
|
|
32
|
+
*
|
|
33
|
+
* @interface OneOfOptions
|
|
34
|
+
* @property {T[]} values - Array of allowed values
|
|
35
|
+
* @property {T | null} [defaultValue] - Default value when input is empty
|
|
36
|
+
* @property {boolean} [caseSensitive=true] - Whether string matching is case-sensitive
|
|
37
|
+
* @property {Function} [transform] - Custom transformation function applied after validation
|
|
38
|
+
* @property {Record<Locale, OneOfMessages>} [i18n] - Custom error messages for different locales
|
|
39
|
+
*/
|
|
40
|
+
export type OneOfOptions<IsRequired extends boolean = true, T extends string | number = string | number> = {
|
|
41
|
+
values: T[]
|
|
42
|
+
defaultValue?: IsRequired extends true ? T : T | null
|
|
43
|
+
caseSensitive?: boolean
|
|
44
|
+
transform?: (value: T) => T
|
|
45
|
+
i18n?: Partial<Record<Locale, Partial<OneOfMessages>>>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Type alias for oneOf validation schema based on required flag
|
|
50
|
+
*
|
|
51
|
+
* @template IsRequired - Whether the field is required
|
|
52
|
+
* @template T - The type of allowed values
|
|
53
|
+
*/
|
|
54
|
+
export type OneOfSchema<IsRequired extends boolean, T> = IsRequired extends true ? ZodType<T> : ZodType<T | null>
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a Zod schema for single-select validation that restricts values to a predefined set
|
|
58
|
+
*
|
|
59
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
60
|
+
* @template T - The type of allowed values (string | number)
|
|
61
|
+
* @param {IsRequired} [required=false] - Whether the field is required
|
|
62
|
+
* @param {OneOfOptions<IsRequired, T>} options - Configuration options (values is required)
|
|
63
|
+
* @returns {OneOfSchema<IsRequired, T>} Zod schema for oneOf validation
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* // Basic single-select validation (optional by default)
|
|
68
|
+
* const roleSchema = oneOf(false, { values: ["admin", "editor", "viewer"] })
|
|
69
|
+
* roleSchema.parse("admin") // ✓ "admin"
|
|
70
|
+
* roleSchema.parse(null) // ✓ null
|
|
71
|
+
*
|
|
72
|
+
* // Required
|
|
73
|
+
* const statusSchema = oneOf(true, { values: ["active", "inactive", "pending"] })
|
|
74
|
+
* statusSchema.parse("active") // ✓ "active"
|
|
75
|
+
* statusSchema.parse(null) // ✗ Required
|
|
76
|
+
* statusSchema.parse("banned") // ✗ Invalid
|
|
77
|
+
*
|
|
78
|
+
* // Numeric values
|
|
79
|
+
* const prioritySchema = oneOf(true, { values: [1, 2, 3, 4, 5] })
|
|
80
|
+
* prioritySchema.parse(3) // ✓ 3
|
|
81
|
+
* prioritySchema.parse(10) // ✗ Invalid
|
|
82
|
+
*
|
|
83
|
+
* // Case-insensitive matching
|
|
84
|
+
* const colorSchema = oneOf(true, {
|
|
85
|
+
* values: ["red", "green", "blue"],
|
|
86
|
+
* caseSensitive: false
|
|
87
|
+
* })
|
|
88
|
+
* colorSchema.parse("RED") // ✓ "red" (normalized to match original)
|
|
89
|
+
* colorSchema.parse("Green") // ✓ "green"
|
|
90
|
+
*
|
|
91
|
+
* // With default value
|
|
92
|
+
* const tierSchema = oneOf(false, {
|
|
93
|
+
* values: ["free", "pro", "enterprise"],
|
|
94
|
+
* defaultValue: "free"
|
|
95
|
+
* })
|
|
96
|
+
* tierSchema.parse(null) // ✓ "free"
|
|
97
|
+
*
|
|
98
|
+
* // With transform
|
|
99
|
+
* const sizeSchema = oneOf(true, {
|
|
100
|
+
* values: ["s", "m", "l", "xl"],
|
|
101
|
+
* transform: (val) => val.toUpperCase() as any
|
|
102
|
+
* })
|
|
103
|
+
* sizeSchema.parse("m") // ✓ "M"
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function oneOf<IsRequired extends boolean = false, T extends string | number = string | number>(
|
|
107
|
+
required?: IsRequired,
|
|
108
|
+
options?: Omit<OneOfOptions<IsRequired, T>, "required">,
|
|
109
|
+
): OneOfSchema<IsRequired, T> {
|
|
110
|
+
const { values = [] as unknown as T[], defaultValue = null, caseSensitive = true, transform, i18n } = options ?? {}
|
|
111
|
+
|
|
112
|
+
const isRequired = required ?? (false as IsRequired)
|
|
113
|
+
|
|
114
|
+
const getMessage = (key: keyof OneOfMessages, params?: Record<string, any>) => {
|
|
115
|
+
if (i18n) {
|
|
116
|
+
const currentLocale = getLocale()
|
|
117
|
+
const customMessages = i18n[currentLocale]
|
|
118
|
+
if (customMessages && customMessages[key]) {
|
|
119
|
+
const template = customMessages[key]!
|
|
120
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return t(`common.oneOf.${key}`, params)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const preprocessFn = (val: unknown) => {
|
|
127
|
+
if (val === "" || val === null || val === undefined) {
|
|
128
|
+
return defaultValue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Coerce number strings to numbers when values contains numbers
|
|
132
|
+
const hasNumbers = values.some((v) => typeof v === "number")
|
|
133
|
+
if (hasNumbers && typeof val === "string" && !isNaN(Number(val)) && val.trim() !== "") {
|
|
134
|
+
const numVal = Number(val)
|
|
135
|
+
if ((values as number[]).includes(numVal)) return numVal
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Case-insensitive normalization for string values
|
|
139
|
+
if (!caseSensitive && typeof val === "string") {
|
|
140
|
+
const match = values.find((v) => typeof v === "string" && v.toLowerCase() === val.toLowerCase())
|
|
141
|
+
if (match !== undefined) return match
|
|
142
|
+
return val
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return val
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const baseSchema = z.preprocess(preprocessFn, z.any())
|
|
149
|
+
|
|
150
|
+
const schema = baseSchema
|
|
151
|
+
.superRefine((val, ctx) => {
|
|
152
|
+
if (val === null) {
|
|
153
|
+
if (isRequired) {
|
|
154
|
+
ctx.addIssue({ code: "custom", message: getMessage("required") })
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!values.includes(val as T)) {
|
|
160
|
+
ctx.addIssue({
|
|
161
|
+
code: "custom",
|
|
162
|
+
message: getMessage("invalid", { values: values.join(", ") }),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.transform((val) => {
|
|
167
|
+
if (val === null || !transform) return val
|
|
168
|
+
return transform(val as T)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return schema as unknown as OneOfSchema<IsRequired, T>
|
|
172
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import { manyOf, setLocale, Locale } from "../../src"
|
|
3
|
+
|
|
4
|
+
const locales = [
|
|
5
|
+
{
|
|
6
|
+
locale: "en-US",
|
|
7
|
+
messages: {
|
|
8
|
+
required: "Required",
|
|
9
|
+
invalid: "Must be from: react, vue, angular, svelte",
|
|
10
|
+
minSelect: "Must select at least 2 item(s)",
|
|
11
|
+
maxSelect: "Must select at most 3 item(s)",
|
|
12
|
+
duplicate: "Duplicate values are not allowed",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
locale: "zh-TW",
|
|
17
|
+
messages: {
|
|
18
|
+
required: "必填",
|
|
19
|
+
invalid: "必須為以下值之一:react, vue, angular, svelte",
|
|
20
|
+
minSelect: "至少需選擇 2 項",
|
|
21
|
+
maxSelect: "最多只能選擇 3 項",
|
|
22
|
+
duplicate: "不允許重複的值",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
const frameworks = ["react", "vue", "angular", "svelte"] as const
|
|
28
|
+
|
|
29
|
+
describe.each(locales)("manyOf(true) locale: $locale", ({ locale, messages }) => {
|
|
30
|
+
beforeEach(() => setLocale(locale as Locale))
|
|
31
|
+
|
|
32
|
+
describe("basic functionality", () => {
|
|
33
|
+
it("should accept valid arrays", () => {
|
|
34
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
35
|
+
expect(schema.parse(["react"])).toEqual(["react"])
|
|
36
|
+
expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
|
|
37
|
+
expect(schema.parse(["react", "vue", "angular", "svelte"])).toEqual(["react", "vue", "angular", "svelte"])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("should reject invalid values in array", () => {
|
|
41
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
42
|
+
expect(() => schema.parse(["react", "jquery"])).toThrow(messages.invalid)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("should fail with empty when required", () => {
|
|
46
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
47
|
+
expect(() => schema.parse(null)).toThrow(messages.required)
|
|
48
|
+
expect(() => schema.parse(undefined)).toThrow(messages.required)
|
|
49
|
+
expect(() => schema.parse("")).toThrow(messages.required)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("should pass with null when not required", () => {
|
|
53
|
+
const schema = manyOf(false, { values: [...frameworks] })
|
|
54
|
+
expect(schema.parse(null)).toBe(null)
|
|
55
|
+
expect(schema.parse(undefined)).toBe(null)
|
|
56
|
+
expect(schema.parse("")).toBe(null)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("should wrap single value in array", () => {
|
|
60
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
61
|
+
expect(schema.parse("react")).toEqual(["react"])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe("min/max selection", () => {
|
|
66
|
+
it("should enforce minimum selections", () => {
|
|
67
|
+
const schema = manyOf(true, { values: [...frameworks], min: 2 })
|
|
68
|
+
expect(() => schema.parse(["react"])).toThrow(messages.minSelect)
|
|
69
|
+
expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("should enforce maximum selections", () => {
|
|
73
|
+
const schema = manyOf(true, { values: [...frameworks], max: 3 })
|
|
74
|
+
expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow(messages.maxSelect)
|
|
75
|
+
expect(schema.parse(["react", "vue", "angular"])).toEqual(["react", "vue", "angular"])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("should enforce both min and max", () => {
|
|
79
|
+
const schema = manyOf(true, { values: [...frameworks], min: 2, max: 3 })
|
|
80
|
+
expect(() => schema.parse(["react"])).toThrow(messages.minSelect)
|
|
81
|
+
expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow(messages.maxSelect)
|
|
82
|
+
expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
|
|
83
|
+
expect(schema.parse(["react", "vue", "angular"])).toEqual(["react", "vue", "angular"])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("duplicate handling", () => {
|
|
88
|
+
it("should reject duplicates by default", () => {
|
|
89
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
90
|
+
expect(() => schema.parse(["react", "react"])).toThrow(messages.duplicate)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("should allow duplicates when configured", () => {
|
|
94
|
+
const schema = manyOf(true, { values: [...frameworks], allowDuplicates: true })
|
|
95
|
+
expect(schema.parse(["react", "react"])).toEqual(["react", "react"])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("numeric values", () => {
|
|
100
|
+
it("should accept valid numeric arrays", () => {
|
|
101
|
+
const schema = manyOf(true, { values: [1, 2, 3, 4, 5] })
|
|
102
|
+
expect(schema.parse([1, 3, 5])).toEqual([1, 3, 5])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("should reject invalid numeric values", () => {
|
|
106
|
+
const schema = manyOf(true, { values: [1, 2, 3] })
|
|
107
|
+
expect(() => schema.parse([1, 10])).toThrow()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("should coerce number strings", () => {
|
|
111
|
+
const schema = manyOf(true, { values: [1, 2, 3] })
|
|
112
|
+
expect(schema.parse(["1", "2"])).toEqual([1, 2])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("case sensitivity", () => {
|
|
117
|
+
it("should be case sensitive by default", () => {
|
|
118
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
119
|
+
expect(() => schema.parse(["React"])).toThrow()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("should match case-insensitively when configured", () => {
|
|
123
|
+
const schema = manyOf(true, { values: [...frameworks], caseSensitive: false })
|
|
124
|
+
expect(schema.parse(["REACT", "Vue"])).toEqual(["react", "vue"])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe("default value", () => {
|
|
129
|
+
it("should use default value when input is empty", () => {
|
|
130
|
+
const schema = manyOf(false, { values: [...frameworks], defaultValue: ["react"] })
|
|
131
|
+
expect(schema.parse(null)).toEqual(["react"])
|
|
132
|
+
expect(schema.parse(undefined)).toEqual(["react"])
|
|
133
|
+
expect(schema.parse("")).toEqual(["react"])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("should use provided value over default", () => {
|
|
137
|
+
const schema = manyOf(false, { values: [...frameworks], defaultValue: ["react"] })
|
|
138
|
+
expect(schema.parse(["vue", "angular"])).toEqual(["vue", "angular"])
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("transform", () => {
|
|
143
|
+
it("should apply transform to valid arrays", () => {
|
|
144
|
+
const schema = manyOf(true, {
|
|
145
|
+
values: [...frameworks],
|
|
146
|
+
transform: (vals) => vals.map((v) => v.toUpperCase()) as any,
|
|
147
|
+
})
|
|
148
|
+
expect(schema.parse(["react", "vue"])).toEqual(["REACT", "VUE"])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe("custom i18n", () => {
|
|
153
|
+
it("should use custom messages when provided", () => {
|
|
154
|
+
const schema = manyOf(true, {
|
|
155
|
+
values: [...frameworks],
|
|
156
|
+
i18n: {
|
|
157
|
+
"en-US": { required: "Pick frameworks!", duplicate: "No repeats!" },
|
|
158
|
+
"zh-TW": { required: "請選擇框架!", duplicate: "不能重複!" },
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (locale === "en-US") {
|
|
163
|
+
expect(() => schema.parse(null)).toThrow("Pick frameworks!")
|
|
164
|
+
expect(() => schema.parse(["react", "react"])).toThrow("No repeats!")
|
|
165
|
+
} else {
|
|
166
|
+
expect(() => schema.parse(null)).toThrow("請選擇框架!")
|
|
167
|
+
expect(() => schema.parse(["react", "react"])).toThrow("不能重複!")
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe("complex scenarios", () => {
|
|
173
|
+
it("should work with all options combined", () => {
|
|
174
|
+
const schema = manyOf(true, {
|
|
175
|
+
values: [...frameworks],
|
|
176
|
+
min: 1,
|
|
177
|
+
max: 3,
|
|
178
|
+
caseSensitive: false,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
expect(schema.parse(["REACT", "Vue"])).toEqual(["react", "vue"])
|
|
182
|
+
expect(() => schema.parse([])).toThrow() // min 1
|
|
183
|
+
expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow() // max 3
|
|
184
|
+
expect(() => schema.parse(["jquery"])).toThrow() // invalid
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("should handle empty array when required with no min", () => {
|
|
188
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
189
|
+
// Empty array is a valid array, just has 0 items — not null
|
|
190
|
+
expect(schema.parse([])).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("should handle empty array with min constraint", () => {
|
|
194
|
+
const schema = manyOf(true, { values: [...frameworks], min: 1 })
|
|
195
|
+
expect(() => schema.parse([])).toThrow()
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import { oneOf, setLocale, Locale } from "../../src"
|
|
3
|
+
|
|
4
|
+
const locales = [
|
|
5
|
+
{
|
|
6
|
+
locale: "en-US",
|
|
7
|
+
messages: {
|
|
8
|
+
required: "Required",
|
|
9
|
+
invalid: "Must be one of: admin, editor, viewer",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
locale: "zh-TW",
|
|
14
|
+
messages: {
|
|
15
|
+
required: "必填",
|
|
16
|
+
invalid: "必須為以下其中一個值:admin, editor, viewer",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
const stringValues = ["admin", "editor", "viewer"] as const
|
|
22
|
+
const numericValues = [1, 2, 3, 4, 5] as const
|
|
23
|
+
|
|
24
|
+
describe.each(locales)("oneOf(true) locale: $locale", ({ locale, messages }) => {
|
|
25
|
+
beforeEach(() => setLocale(locale as Locale))
|
|
26
|
+
|
|
27
|
+
describe("basic functionality", () => {
|
|
28
|
+
it("should accept valid values", () => {
|
|
29
|
+
const schema = oneOf(true, { values: [...stringValues] })
|
|
30
|
+
expect(schema.parse("admin")).toBe("admin")
|
|
31
|
+
expect(schema.parse("editor")).toBe("editor")
|
|
32
|
+
expect(schema.parse("viewer")).toBe("viewer")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("should reject invalid values", () => {
|
|
36
|
+
const schema = oneOf(true, { values: [...stringValues] })
|
|
37
|
+
expect(() => schema.parse("hacker")).toThrow(messages.invalid)
|
|
38
|
+
expect(() => schema.parse("superadmin")).toThrow(messages.invalid)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("should fail with empty when required", () => {
|
|
42
|
+
const schema = oneOf(true, { values: [...stringValues] })
|
|
43
|
+
expect(() => schema.parse("")).toThrow(messages.required)
|
|
44
|
+
expect(() => schema.parse(null)).toThrow(messages.required)
|
|
45
|
+
expect(() => schema.parse(undefined)).toThrow(messages.required)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("should pass with null when not required", () => {
|
|
49
|
+
const schema = oneOf(false, { values: [...stringValues] })
|
|
50
|
+
expect(schema.parse("")).toBe(null)
|
|
51
|
+
expect(schema.parse(null)).toBe(null)
|
|
52
|
+
expect(schema.parse(undefined)).toBe(null)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("numeric values", () => {
|
|
57
|
+
it("should accept valid numeric values", () => {
|
|
58
|
+
const schema = oneOf(true, { values: [...numericValues] })
|
|
59
|
+
expect(schema.parse(1)).toBe(1)
|
|
60
|
+
expect(schema.parse(3)).toBe(3)
|
|
61
|
+
expect(schema.parse(5)).toBe(5)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("should reject invalid numeric values", () => {
|
|
65
|
+
const schema = oneOf(true, { values: [...numericValues] })
|
|
66
|
+
expect(() => schema.parse(10)).toThrow()
|
|
67
|
+
expect(() => schema.parse(0)).toThrow()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("should coerce number strings to numbers", () => {
|
|
71
|
+
const schema = oneOf(true, { values: [...numericValues] })
|
|
72
|
+
expect(schema.parse("3")).toBe(3)
|
|
73
|
+
expect(schema.parse("5")).toBe(5)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("case sensitivity", () => {
|
|
78
|
+
it("should be case sensitive by default", () => {
|
|
79
|
+
const schema = oneOf(true, { values: [...stringValues] })
|
|
80
|
+
expect(() => schema.parse("Admin")).toThrow()
|
|
81
|
+
expect(() => schema.parse("EDITOR")).toThrow()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("should match case-insensitively when configured", () => {
|
|
85
|
+
const schema = oneOf(true, { values: [...stringValues], caseSensitive: false })
|
|
86
|
+
expect(schema.parse("Admin")).toBe("admin")
|
|
87
|
+
expect(schema.parse("EDITOR")).toBe("editor")
|
|
88
|
+
expect(schema.parse("VIEWER")).toBe("viewer")
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("default value", () => {
|
|
93
|
+
it("should use default value when input is empty", () => {
|
|
94
|
+
const schema = oneOf(false, { values: [...stringValues], defaultValue: "viewer" })
|
|
95
|
+
expect(schema.parse("")).toBe("viewer")
|
|
96
|
+
expect(schema.parse(null)).toBe("viewer")
|
|
97
|
+
expect(schema.parse(undefined)).toBe("viewer")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("should use provided value over default", () => {
|
|
101
|
+
const schema = oneOf(false, { values: [...stringValues], defaultValue: "viewer" })
|
|
102
|
+
expect(schema.parse("admin")).toBe("admin")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("transform", () => {
|
|
107
|
+
it("should apply transform to valid values", () => {
|
|
108
|
+
const schema = oneOf(true, {
|
|
109
|
+
values: [...stringValues],
|
|
110
|
+
transform: (val) => val.toUpperCase() as any,
|
|
111
|
+
})
|
|
112
|
+
expect(schema.parse("admin")).toBe("ADMIN")
|
|
113
|
+
expect(schema.parse("editor")).toBe("EDITOR")
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("custom i18n", () => {
|
|
118
|
+
it("should use custom messages when provided", () => {
|
|
119
|
+
const schema = oneOf(true, {
|
|
120
|
+
values: [...stringValues],
|
|
121
|
+
i18n: {
|
|
122
|
+
"en-US": { required: "Pick a role!", invalid: "Bad role!" },
|
|
123
|
+
"zh-TW": { required: "請選擇角色!", invalid: "無效角色!" },
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (locale === "en-US") {
|
|
128
|
+
expect(() => schema.parse("")).toThrow("Pick a role!")
|
|
129
|
+
expect(() => schema.parse("hacker")).toThrow("Bad role!")
|
|
130
|
+
} else {
|
|
131
|
+
expect(() => schema.parse("")).toThrow("請選擇角色!")
|
|
132
|
+
expect(() => schema.parse("hacker")).toThrow("無效角色!")
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
package/tsup.config.ts
CHANGED
|
@@ -9,7 +9,9 @@ export default defineConfig({
|
|
|
9
9
|
"common/email": "src/validators/common/email.ts",
|
|
10
10
|
"common/file": "src/validators/common/file.ts",
|
|
11
11
|
"common/id": "src/validators/common/id.ts",
|
|
12
|
+
"common/many-of": "src/validators/common/many-of.ts",
|
|
12
13
|
"common/number": "src/validators/common/number.ts",
|
|
14
|
+
"common/one-of": "src/validators/common/one-of.ts",
|
|
13
15
|
"common/password": "src/validators/common/password.ts",
|
|
14
16
|
"common/text": "src/validators/common/text.ts",
|
|
15
17
|
"common/time": "src/validators/common/time.ts",
|