@tuturuuu/utils 0.0.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,335 @@
1
+ import { type NextRequest, NextResponse } from 'next/server';
2
+
3
+ export const MAX_EMOJIS_PER_FIELD = 10;
4
+ export const MAX_SHORT_TEXT_FIELD_GRAPHEMES = 280;
5
+ /** Form description, task description, etc. Allow up to 100k to match database limits. */
6
+ export const MAX_DESCRIPTION_FIELD_GRAPHEMES = 100_000;
7
+
8
+ export type RequestContentViolation = {
9
+ code: 'EMOJI_LIMIT_EXCEEDED' | 'TEXT_BOMB_DETECTED';
10
+ count: number;
11
+ limit: number;
12
+ message: string;
13
+ path: string;
14
+ };
15
+
16
+ type FindRequestContentViolationOptions = {
17
+ skipValidationForFields?: string[];
18
+ skipEmojiCheckForFields?: string[];
19
+ skipShortTextLengthCheckForFields?: string[];
20
+ };
21
+
22
+ const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
23
+ const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;
24
+ const FLAG_RE = /^(?:\p{Regional_Indicator}{2})$/u;
25
+ const KEYCAP_RE = /^(?:[#*0-9]\uFE0F?\u20E3)$/u;
26
+ const DESCRIPTION_FIELD_NAMES = new Set(['description']);
27
+ const SHORT_TEXT_FIELD_NAMES = new Set([
28
+ 'alias',
29
+ 'category',
30
+ 'displayName',
31
+ 'display_name',
32
+ 'fullName',
33
+ 'full_name',
34
+ 'icon',
35
+ 'label',
36
+ 'name',
37
+ 'slug',
38
+ 'subject',
39
+ 'summary',
40
+ 'tag',
41
+ 'title',
42
+ 'walletName',
43
+ 'wallet_name',
44
+ ]);
45
+ const graphemeSegmenter =
46
+ typeof Intl !== 'undefined' && 'Segmenter' in Intl
47
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
48
+ : null;
49
+
50
+ function hasJsonContentType(contentType: string | null): boolean {
51
+ if (!contentType) return false;
52
+
53
+ const normalized = contentType.split(';', 1)[0]?.trim().toLowerCase();
54
+ return normalized === 'application/json' || !!normalized?.endsWith('+json');
55
+ }
56
+
57
+ function getTrustedBypassTokens(): string[] {
58
+ return [
59
+ process.env.CRON_SECRET,
60
+ process.env.VERCEL_CRON_SECRET,
61
+ process.env.SUPABASE_SERVICE_ROLE_KEY,
62
+ ]
63
+ .map((token) => token?.trim())
64
+ .filter((token): token is string => Boolean(token));
65
+ }
66
+
67
+ function getGraphemeSegments(value: string): string[] {
68
+ if (!graphemeSegmenter) {
69
+ return Array.from(value);
70
+ }
71
+
72
+ return Array.from(graphemeSegmenter.segment(value), ({ segment }) => segment);
73
+ }
74
+
75
+ export function countEmojisInString(value: string): number {
76
+ let emojiCount = 0;
77
+
78
+ for (const segment of getGraphemeSegments(value)) {
79
+ if (
80
+ EXTENDED_PICTOGRAPHIC_RE.test(segment) ||
81
+ FLAG_RE.test(segment) ||
82
+ KEYCAP_RE.test(segment)
83
+ ) {
84
+ emojiCount += 1;
85
+ }
86
+ }
87
+
88
+ return emojiCount;
89
+ }
90
+
91
+ function getLastFieldName(path: string): string | null {
92
+ const withoutIndexes = path.replace(/\[\d+\]/g, '');
93
+ const segments = withoutIndexes.split('.');
94
+ const lastSegment = segments.at(-1);
95
+ return lastSegment && lastSegment !== 'body' ? lastSegment : null;
96
+ }
97
+
98
+ function isShortTextFieldPath(path: string): boolean {
99
+ const fieldName = getLastFieldName(path);
100
+ return fieldName ? SHORT_TEXT_FIELD_NAMES.has(fieldName) : false;
101
+ }
102
+
103
+ function isDescriptionFieldPath(path: string): boolean {
104
+ const fieldName = getLastFieldName(path);
105
+ return fieldName ? DESCRIPTION_FIELD_NAMES.has(fieldName) : false;
106
+ }
107
+
108
+ function getStringContentViolation(
109
+ value: string,
110
+ path: string,
111
+ options?: FindRequestContentViolationOptions
112
+ ): RequestContentViolation | null {
113
+ const lastFieldName = getLastFieldName(path);
114
+ const shouldSkipValidationForField =
115
+ !!lastFieldName &&
116
+ (options?.skipValidationForFields ?? []).includes(lastFieldName);
117
+
118
+ if (shouldSkipValidationForField) {
119
+ return null;
120
+ }
121
+
122
+ const shouldSkipEmojiLimitForField =
123
+ !!lastFieldName &&
124
+ (options?.skipEmojiCheckForFields ?? []).includes(lastFieldName);
125
+
126
+ if (!shouldSkipEmojiLimitForField) {
127
+ const emojiCount = countEmojisInString(value);
128
+ if (emojiCount > MAX_EMOJIS_PER_FIELD) {
129
+ return {
130
+ code: 'EMOJI_LIMIT_EXCEEDED',
131
+ count: emojiCount,
132
+ limit: MAX_EMOJIS_PER_FIELD,
133
+ message: `Field "${path}" cannot contain more than ${MAX_EMOJIS_PER_FIELD} emojis`,
134
+ path,
135
+ };
136
+ }
137
+ }
138
+
139
+ const graphemes = getGraphemeSegments(value);
140
+ if (isDescriptionFieldPath(path)) {
141
+ if (graphemes.length > MAX_DESCRIPTION_FIELD_GRAPHEMES) {
142
+ return {
143
+ code: 'TEXT_BOMB_DETECTED',
144
+ count: graphemes.length,
145
+ limit: MAX_DESCRIPTION_FIELD_GRAPHEMES,
146
+ message: `Field "${path}" exceeds the maximum description length`,
147
+ path,
148
+ };
149
+ }
150
+ } else if (
151
+ isShortTextFieldPath(path) &&
152
+ !(
153
+ !!lastFieldName &&
154
+ (options?.skipShortTextLengthCheckForFields ?? []).includes(lastFieldName)
155
+ ) &&
156
+ graphemes.length > MAX_SHORT_TEXT_FIELD_GRAPHEMES
157
+ ) {
158
+ return {
159
+ code: 'TEXT_BOMB_DETECTED',
160
+ count: graphemes.length,
161
+ limit: MAX_SHORT_TEXT_FIELD_GRAPHEMES,
162
+ message: `Field "${path}" exceeds the maximum short-field length`,
163
+ path,
164
+ };
165
+ }
166
+
167
+ return null;
168
+ }
169
+
170
+ export function findRequestContentViolation(
171
+ value: unknown,
172
+ path = 'body',
173
+ options?: FindRequestContentViolationOptions
174
+ ): RequestContentViolation | null {
175
+ if (typeof value === 'string') {
176
+ return getStringContentViolation(value, path, options);
177
+ }
178
+
179
+ if (Array.isArray(value)) {
180
+ for (const [index, item] of value.entries()) {
181
+ const violation = findRequestContentViolation(
182
+ item,
183
+ `${path}[${index}]`,
184
+ options
185
+ );
186
+ if (violation) {
187
+ return violation;
188
+ }
189
+ }
190
+
191
+ return null;
192
+ }
193
+
194
+ if (value && typeof value === 'object') {
195
+ for (const [key, item] of Object.entries(value)) {
196
+ const violation = findRequestContentViolation(
197
+ item,
198
+ `${path}.${key}`,
199
+ options
200
+ );
201
+ if (violation) {
202
+ return violation;
203
+ }
204
+ }
205
+ }
206
+
207
+ return null;
208
+ }
209
+
210
+ export function shouldValidateEmojiLimit(
211
+ request: Pick<NextRequest, 'method' | 'headers'>
212
+ ): boolean {
213
+ return (
214
+ BODY_METHODS.has(request.method.toUpperCase()) &&
215
+ hasJsonContentType(request.headers.get('content-type'))
216
+ );
217
+ }
218
+
219
+ export function isTrustedEmojiBypassRequest(
220
+ request: Pick<NextRequest, 'headers'>
221
+ ): boolean {
222
+ const authorization = request.headers.get('authorization')?.trim();
223
+ if (!authorization) {
224
+ return false;
225
+ }
226
+
227
+ const match = /^Bearer\s+(.+)$/i.exec(authorization);
228
+ if (!match) {
229
+ return false;
230
+ }
231
+
232
+ const token = match[1]?.trim();
233
+ return token ? getTrustedBypassTokens().includes(token) : false;
234
+ }
235
+
236
+ export async function getRequestContentViolationForRequest(
237
+ request: NextRequest,
238
+ options?: {
239
+ allowDescriptionYjsState?: boolean;
240
+ skipValidationForFields?: string[];
241
+ }
242
+ ): Promise<RequestContentViolation | null> {
243
+ if (
244
+ !shouldValidateEmojiLimit(request) ||
245
+ isTrustedEmojiBypassRequest(request)
246
+ ) {
247
+ return null;
248
+ }
249
+
250
+ let rawBody = '';
251
+ try {
252
+ rawBody = await request.clone().text();
253
+ } catch {
254
+ return null;
255
+ }
256
+
257
+ if (!rawBody.trim()) {
258
+ return null;
259
+ }
260
+
261
+ try {
262
+ const parsedBody = JSON.parse(rawBody);
263
+
264
+ if (
265
+ options?.allowDescriptionYjsState === true &&
266
+ parsedBody &&
267
+ typeof parsedBody === 'object' &&
268
+ 'description_yjs_state' in parsedBody &&
269
+ typeof (parsedBody as { description?: unknown }).description === 'string'
270
+ ) {
271
+ const {
272
+ description,
273
+ // Explicitly strip description_yjs_state from validation when allowed
274
+ // so Yjs payloads do not trigger short-field or emoji limits.
275
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
276
+ description_yjs_state,
277
+ ...rest
278
+ } = parsedBody as {
279
+ description: string;
280
+ description_yjs_state?: unknown;
281
+ [key: string]: unknown;
282
+ };
283
+
284
+ return findRequestContentViolation(
285
+ {
286
+ ...rest,
287
+ description,
288
+ },
289
+ 'body',
290
+ {
291
+ skipValidationForFields: options?.skipValidationForFields,
292
+ skipEmojiCheckForFields: ['description'],
293
+ skipShortTextLengthCheckForFields: ['description'],
294
+ }
295
+ );
296
+ }
297
+
298
+ return findRequestContentViolation(parsedBody, 'body', {
299
+ skipValidationForFields: options?.skipValidationForFields,
300
+ });
301
+ } catch {
302
+ // Let route handlers keep their own invalid JSON behavior.
303
+ return null;
304
+ }
305
+ }
306
+
307
+ export function createRequestContentViolationResponse(
308
+ violation: RequestContentViolation
309
+ ): NextResponse {
310
+ return NextResponse.json(
311
+ {
312
+ error: 'Bad Request',
313
+ message: violation.message,
314
+ code: violation.code,
315
+ field: violation.path,
316
+ count: violation.count,
317
+ limit: violation.limit,
318
+ },
319
+ { status: 400 }
320
+ );
321
+ }
322
+
323
+ export async function validateRequestEmojiLimit(
324
+ request: NextRequest,
325
+ options?: {
326
+ allowDescriptionYjsState?: boolean;
327
+ skipValidationForFields?: string[];
328
+ }
329
+ ): Promise<NextResponse | null> {
330
+ const violation = await getRequestContentViolationForRequest(
331
+ request,
332
+ options
333
+ );
334
+ return violation ? createRequestContentViolationResponse(violation) : null;
335
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Sanitizes a search query by trimming it and removing control characters.
3
+ * @param value The search query to sanitize
4
+ * @returns The sanitized search query or null if empty
5
+ */
6
+ export const sanitizeSearchQuery = (value?: string | null): string | null => {
7
+ if (!value) return null;
8
+ const sanitized = value.replace(/\p{Cc}/gu, '').trim();
9
+ return sanitized.length > 0 ? sanitized : null;
10
+ };
11
+
12
+ /**
13
+ * Escapes special characters in a string for use in a SQL LIKE pattern.
14
+ * @param value The string to escape
15
+ * @returns The escaped string
16
+ */
17
+ export const escapeLikePattern = (value: string): string =>
18
+ value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ compactIntentText,
4
+ getIntentAcronym,
5
+ normalizeIntentText,
6
+ searchIntent,
7
+ } from './search';
8
+
9
+ type TestItem = {
10
+ aliases?: string[];
11
+ id: string;
12
+ title: string;
13
+ };
14
+
15
+ const items: TestItem[] = [
16
+ { id: 'alpha', title: 'Alpha Workspace' },
17
+ { id: 'acme', title: 'Acme Finance Operations' },
18
+ { id: 'banana', title: 'Banana Lab' },
19
+ { id: 'data', title: 'Data Science Team', aliases: ['DST'] },
20
+ { id: 'viet', title: 'Tiếng Việt Của Tôi' },
21
+ { id: 'qr', title: 'QR Generator', aliases: ['Quick Response'] },
22
+ ];
23
+
24
+ describe('intent search', () => {
25
+ it('normalizes accents, punctuation, spacing, and Vietnamese d variants', () => {
26
+ expect(normalizeIntentText(' Tiếng---Việt Đẹp ')).toBe('tieng viet dep');
27
+ expect(compactIntentText('Alpha_Workspace')).toBe('alphaworkspace');
28
+ });
29
+
30
+ it('builds acronyms from normalized words', () => {
31
+ expect(getIntentAcronym('Acme Finance Operations')).toBe('afo');
32
+ });
33
+
34
+ it('matches close workspace names with bounded typos', () => {
35
+ const [result] = searchIntent(items, 'alhpa workspace');
36
+
37
+ expect(result?.item.id).toBe('alpha');
38
+ expect(result?.reason).toBe('typo');
39
+ });
40
+
41
+ it('matches punctuation and spacing insensitive queries', () => {
42
+ const [result] = searchIntent(items, 'alpha-work space');
43
+
44
+ expect(result?.item.id).toBe('alpha');
45
+ expect(result?.reason).toBe('compact');
46
+ });
47
+
48
+ it('matches acronyms and aliases', () => {
49
+ expect(searchIntent(items, 'afo')[0]?.item.id).toBe('acme');
50
+ expect(searchIntent(items, 'dst')[0]?.item.id).toBe('data');
51
+ });
52
+
53
+ it('matches words in query order independent of target word order', () => {
54
+ const [result] = searchIntent(items, 'team data');
55
+
56
+ expect(result?.item.id).toBe('data');
57
+ expect(result?.reason).toBe('word-order');
58
+ });
59
+
60
+ it('matches Vietnamese accents with unaccented input', () => {
61
+ const [result] = searchIntent(items, 'tieng viet');
62
+
63
+ expect(result?.item.id).toBe('viet');
64
+ });
65
+
66
+ it('limits short-query noise to strong matches', () => {
67
+ const results = searchIntent(items, 'ba');
68
+
69
+ expect(results.map((result) => result.item.id)).toEqual(['banana']);
70
+ });
71
+
72
+ it('keeps result ordering stable for equal scores', () => {
73
+ const stableItems = [
74
+ { id: 'one', title: 'Alpha Team' },
75
+ { id: 'two', title: 'Alpha Squad' },
76
+ ];
77
+
78
+ expect(
79
+ searchIntent(stableItems, 'alpha').map((result) => result.item.id)
80
+ ).toEqual(['one', 'two']);
81
+ });
82
+
83
+ it('uses ordered fuzzy matching for intentional abbreviations', () => {
84
+ const [result] = searchIntent(items, 'qgn');
85
+
86
+ expect(result?.item.id).toBe('qr');
87
+ expect(result?.reason).toBe('fuzzy');
88
+ });
89
+ });