@tuturuuu/utils 0.0.2 → 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 +122 -3
  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,379 @@
1
+ /**
2
+ * Notification Service Utilities
3
+ *
4
+ * Provides utilities for creating, managing, and querying notifications
5
+ * across web, email, SMS, and push notification channels.
6
+ */
7
+
8
+ import { createClient } from '@tuturuuu/supabase/next/server';
9
+
10
+ export type NotificationType =
11
+ | 'task_assigned'
12
+ | 'task_updated'
13
+ | 'task_mention'
14
+ | 'deadline_reminder'
15
+ | 'time_tracking_request_submitted'
16
+ | 'time_tracking_request_resubmitted'
17
+ | 'time_tracking_request_approved'
18
+ | 'time_tracking_request_rejected'
19
+ | 'time_tracking_request_needs_info'
20
+ | 'workspace_invite';
21
+
22
+ export type NotificationChannel = 'web' | 'email' | 'sms' | 'push';
23
+
24
+ export type NotificationStatus = 'pending' | 'sent' | 'failed';
25
+
26
+ export interface CreateNotificationParams {
27
+ wsId: string;
28
+ userId: string;
29
+ type: NotificationType;
30
+ title: string;
31
+ description?: string;
32
+ data?: Record<string, any>;
33
+ entityType?: string;
34
+ entityId?: string;
35
+ createdBy?: string;
36
+ }
37
+
38
+ export interface GetNotificationsParams {
39
+ wsId: string;
40
+ userId: string;
41
+ limit?: number;
42
+ offset?: number;
43
+ unreadOnly?: boolean;
44
+ type?: NotificationType;
45
+ }
46
+
47
+ /**
48
+ * Creates a notification for a user
49
+ * Respects user preferences for web and email notifications
50
+ */
51
+ export async function createNotification(
52
+ params: CreateNotificationParams
53
+ ): Promise<string | null> {
54
+ const supabase = await createClient();
55
+
56
+ const { data, error } = await supabase.rpc('create_notification', {
57
+ p_ws_id: params.wsId,
58
+ p_user_id: params.userId,
59
+ p_type: params.type,
60
+ p_title: params.title,
61
+ p_description: params.description ?? undefined,
62
+ p_data: params.data || {},
63
+ p_entity_type: params.entityType ?? undefined,
64
+ p_entity_id: params.entityId ?? undefined,
65
+ p_created_by: params.createdBy ?? undefined,
66
+ });
67
+
68
+ if (error) {
69
+ console.error('Error creating notification:', error);
70
+ return null;
71
+ }
72
+
73
+ return data as string | null;
74
+ }
75
+
76
+ /**
77
+ * Gets notifications for a user with pagination and filtering
78
+ */
79
+ export async function getNotifications(params: GetNotificationsParams) {
80
+ const supabase = await createClient();
81
+
82
+ let query = supabase
83
+ .from('notifications')
84
+ .select('*', { count: 'exact' })
85
+ .eq('ws_id', params.wsId)
86
+ .eq('user_id', params.userId)
87
+ .order('created_at', { ascending: false });
88
+
89
+ if (params.unreadOnly) {
90
+ query = query.is('read_at', null);
91
+ }
92
+
93
+ if (params.type) {
94
+ query = query.eq('type', params.type);
95
+ }
96
+
97
+ if (params.limit) {
98
+ query = query.limit(params.limit);
99
+ }
100
+
101
+ if (params.offset) {
102
+ query = query.range(
103
+ params.offset,
104
+ params.offset + (params.limit || 10) - 1
105
+ );
106
+ }
107
+
108
+ const { data, error, count } = await query;
109
+
110
+ if (error) {
111
+ console.error('Error fetching notifications:', error);
112
+ return { notifications: [], count: 0 };
113
+ }
114
+
115
+ return {
116
+ notifications: data || [],
117
+ count: count || 0,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Gets the unread notification count for a user
123
+ */
124
+ export async function getUnreadCount(
125
+ wsId: string,
126
+ userId: string
127
+ ): Promise<number> {
128
+ const supabase = await createClient();
129
+
130
+ const { count, error } = await supabase
131
+ .from('notifications')
132
+ .select('*', { count: 'exact', head: true })
133
+ .eq('ws_id', wsId)
134
+ .eq('user_id', userId)
135
+ .is('read_at', null);
136
+
137
+ if (error) {
138
+ console.error('Error fetching unread count:', error);
139
+ return 0;
140
+ }
141
+
142
+ return count || 0;
143
+ }
144
+
145
+ /**
146
+ * Marks a notification as read
147
+ */
148
+ export async function markAsRead(notificationId: string): Promise<boolean> {
149
+ const supabase = await createClient();
150
+
151
+ const { error } = await supabase
152
+ .from('notifications')
153
+ .update({ read_at: new Date().toISOString() })
154
+ .eq('id', notificationId);
155
+
156
+ if (error) {
157
+ console.error('Error marking notification as read:', error);
158
+ return false;
159
+ }
160
+
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Marks a notification as unread
166
+ */
167
+ export async function markAsUnread(notificationId: string): Promise<boolean> {
168
+ const supabase = await createClient();
169
+
170
+ const { error } = await supabase
171
+ .from('notifications')
172
+ .update({ read_at: null })
173
+ .eq('id', notificationId);
174
+
175
+ if (error) {
176
+ console.error('Error marking notification as unread:', error);
177
+ return false;
178
+ }
179
+
180
+ return true;
181
+ }
182
+
183
+ /**
184
+ * Marks all notifications as read for a user in a workspace
185
+ */
186
+ export async function markAllAsRead(
187
+ wsId: string,
188
+ userId: string
189
+ ): Promise<boolean> {
190
+ const supabase = await createClient();
191
+
192
+ const { error } = await supabase
193
+ .from('notifications')
194
+ .update({ read_at: new Date().toISOString() })
195
+ .eq('ws_id', wsId)
196
+ .eq('user_id', userId)
197
+ .is('read_at', null);
198
+
199
+ if (error) {
200
+ console.error('Error marking all notifications as read:', error);
201
+ return false;
202
+ }
203
+
204
+ return true;
205
+ }
206
+
207
+ /**
208
+ * Deletes a notification
209
+ */
210
+ export async function deleteNotification(
211
+ notificationId: string
212
+ ): Promise<boolean> {
213
+ const supabase = await createClient();
214
+
215
+ const { error } = await supabase
216
+ .from('notifications')
217
+ .delete()
218
+ .eq('id', notificationId);
219
+
220
+ if (error) {
221
+ console.error('Error deleting notification:', error);
222
+ return false;
223
+ }
224
+
225
+ return true;
226
+ }
227
+
228
+ /**
229
+ * Gets notification preferences for a user
230
+ */
231
+ export async function getNotificationPreferences(wsId: string, userId: string) {
232
+ const supabase = await createClient();
233
+
234
+ const { data, error } = await supabase
235
+ .from('notification_preferences')
236
+ .select('*')
237
+ .eq('ws_id', wsId)
238
+ .eq('user_id', userId);
239
+
240
+ if (error) {
241
+ console.error('Error fetching notification preferences:', error);
242
+ return [];
243
+ }
244
+
245
+ return data || [];
246
+ }
247
+
248
+ /**
249
+ * Sets notification preference for a specific event type and channel
250
+ */
251
+ export async function setNotificationPreference(
252
+ wsId: string,
253
+ userId: string,
254
+ eventType: NotificationType,
255
+ channel: NotificationChannel,
256
+ enabled: boolean
257
+ ): Promise<boolean> {
258
+ const supabase = await createClient();
259
+
260
+ const { error } = await supabase.from('notification_preferences').upsert(
261
+ {
262
+ ws_id: wsId,
263
+ user_id: userId,
264
+ event_type: eventType,
265
+ channel,
266
+ enabled,
267
+ },
268
+ {
269
+ onConflict: 'ws_id,user_id,event_type,channel',
270
+ }
271
+ );
272
+
273
+ if (error) {
274
+ console.error('Error setting notification preference:', error);
275
+ return false;
276
+ }
277
+
278
+ return true;
279
+ }
280
+
281
+ /**
282
+ * Checks if a notification should be sent based on user preferences
283
+ * Defaults to true if no preference is set
284
+ */
285
+ export async function shouldSendNotification(
286
+ wsId: string,
287
+ userId: string,
288
+ eventType: NotificationType,
289
+ channel: NotificationChannel
290
+ ): Promise<boolean> {
291
+ const supabase = await createClient();
292
+
293
+ const { data, error } = await supabase.rpc('should_send_notification', {
294
+ p_ws_id: wsId,
295
+ p_user_id: userId,
296
+ p_event_type: eventType,
297
+ p_channel: channel,
298
+ });
299
+
300
+ if (error) {
301
+ console.error('Error checking notification preference:', error);
302
+ // Default to true (enabled) if there's an error
303
+ return true;
304
+ }
305
+
306
+ return data as boolean;
307
+ }
308
+
309
+ /**
310
+ * Scans text for @mentions and returns user IDs
311
+ * Mentions format: @[userId] or @username
312
+ */
313
+ export function extractMentions(text: string): string[] {
314
+ if (!text) return [];
315
+
316
+ // Match @[uuid] pattern
317
+ const uuidPattern =
318
+ /@\[([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/gi;
319
+ const matches = text.matchAll(uuidPattern);
320
+
321
+ const userIds: string[] = [];
322
+ for (const match of matches) {
323
+ if (match[1]) {
324
+ userIds.push(match[1]);
325
+ }
326
+ }
327
+
328
+ return [...new Set(userIds)]; // Remove duplicates
329
+ }
330
+
331
+ /**
332
+ * Creates mention notifications for users mentioned in text
333
+ */
334
+ export async function createMentionNotifications(
335
+ wsId: string,
336
+ text: string,
337
+ entityType: string,
338
+ entityId: string,
339
+ entityName: string,
340
+ createdBy: string
341
+ ): Promise<void> {
342
+ const mentionedUserIds = extractMentions(text);
343
+
344
+ if (mentionedUserIds.length === 0) return;
345
+
346
+ // Get creator name
347
+ const supabase = await createClient();
348
+ const { data: creator } = await supabase
349
+ .from('users')
350
+ .select('display_name')
351
+ .eq('id', createdBy)
352
+ .single();
353
+
354
+ const creatorName = creator?.display_name || 'Someone';
355
+
356
+ // Create notifications for each mentioned user
357
+ for (const userId of mentionedUserIds) {
358
+ // Don't notify the creator
359
+ if (userId === createdBy) continue;
360
+
361
+ await createNotification({
362
+ wsId,
363
+ userId,
364
+ type: 'task_mention',
365
+ title: 'You were mentioned',
366
+ description: `${creatorName} mentioned you in "${entityName}"`,
367
+ data: {
368
+ entity_type: entityType,
369
+ entity_id: entityId,
370
+ entity_name: entityName,
371
+ mentioned_by: createdBy,
372
+ mentioned_by_name: creatorName,
373
+ },
374
+ entityType,
375
+ entityId,
376
+ createdBy,
377
+ });
378
+ }
379
+ }
@@ -0,0 +1,254 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ aggregateByChallenge,
4
+ calculateBestScores,
5
+ calculatePercentage,
6
+ calculateScore,
7
+ formatScore,
8
+ type ScoreInput,
9
+ } from '../calculate';
10
+
11
+ describe('calculateScore', () => {
12
+ it('should correctly calculate score with only tests', () => {
13
+ const input: ScoreInput = {
14
+ total_tests: 10,
15
+ passed_tests: 5,
16
+ total_criteria: 0,
17
+ sum_criterion_score: 0,
18
+ };
19
+
20
+ // With updated logic, only tests should get 100% weight
21
+ expect(calculateScore(input)).toBe(5); // (5/10) * 10 * 1.0 = 5
22
+ });
23
+
24
+ it('should correctly calculate score with only criteria', () => {
25
+ const input: ScoreInput = {
26
+ total_tests: 0,
27
+ passed_tests: 0,
28
+ total_criteria: 5,
29
+ sum_criterion_score: 25, // 5 criteria with average score of 5
30
+ };
31
+
32
+ expect(calculateScore(input)).toBe(5); // (25/(5*10)) * 10 * 1.0 = 5
33
+ });
34
+
35
+ it('should correctly calculate score with both tests and criteria', () => {
36
+ const input: ScoreInput = {
37
+ total_tests: 10,
38
+ passed_tests: 8,
39
+ total_criteria: 5,
40
+ sum_criterion_score: 40, // 5 criteria with average score of 8
41
+ };
42
+
43
+ // Test score: (8/10) * 10 * 0.5 = 4
44
+ // Criteria score: (40/(5*10)) * 10 * 0.5 = 4
45
+ // Total: 4 + 4 = 8
46
+ expect(calculateScore(input)).toBe(8);
47
+ });
48
+
49
+ it('should give full weight to tests when criteria do not exist', () => {
50
+ const input: ScoreInput = {
51
+ total_tests: 10,
52
+ passed_tests: 7,
53
+ total_criteria: 0,
54
+ sum_criterion_score: 0,
55
+ };
56
+
57
+ // No criteria, so tests get 100% weight
58
+ // Test score: (7/10) * 10 * 1.0 = 7
59
+ expect(calculateScore(input)).toBe(7);
60
+ });
61
+
62
+ it('should handle zero test cases with criteria', () => {
63
+ const input: ScoreInput = {
64
+ total_tests: 0,
65
+ passed_tests: 0,
66
+ total_criteria: 5,
67
+ sum_criterion_score: 25, // 5 criteria with average score of 5
68
+ };
69
+
70
+ // No tests, so criteria gets 100% weight
71
+ // Criteria score: (25/(5*10)) * 10 * 1.0 = 5
72
+ expect(calculateScore(input)).toBe(5);
73
+ });
74
+
75
+ it('should handle null values', () => {
76
+ const input: ScoreInput = {
77
+ total_tests: null,
78
+ passed_tests: null,
79
+ total_criteria: null,
80
+ sum_criterion_score: null,
81
+ };
82
+
83
+ expect(calculateScore(input)).toBe(0);
84
+ });
85
+
86
+ it('should handle undefined values', () => {
87
+ const input: ScoreInput = {};
88
+
89
+ expect(calculateScore(input)).toBe(0);
90
+ });
91
+ });
92
+
93
+ describe('calculateBestScores', () => {
94
+ it('should return the best score for each problem', () => {
95
+ const submissions = [
96
+ { problem_id: 'p1', total_tests: 10, passed_tests: 5 },
97
+ { problem_id: 'p1', total_tests: 10, passed_tests: 7 },
98
+ { problem_id: 'p2', total_tests: 10, passed_tests: 3 },
99
+ { problem_id: 'p2', total_tests: 10, passed_tests: 6 },
100
+ ];
101
+
102
+ const bestScores = calculateBestScores(submissions);
103
+
104
+ // With the updated logic: (passed/total) * 10 * 1.0 for tests only
105
+ expect(bestScores.get('p1')).toBe(7); // (7/10) * 10 * 1.0 = 7
106
+ expect(bestScores.get('p2')).toBe(6); // (6/10) * 10 * 1.0 = 6
107
+ });
108
+
109
+ it('should handle submissions with both tests and criteria', () => {
110
+ const submissions = [
111
+ {
112
+ problem_id: 'p1',
113
+ total_tests: 10,
114
+ passed_tests: 8,
115
+ total_criteria: 4,
116
+ sum_criterion_score: 32, // Average of 8 per criterion
117
+ },
118
+ {
119
+ problem_id: 'p1',
120
+ total_tests: 10,
121
+ passed_tests: 6,
122
+ total_criteria: 5,
123
+ sum_criterion_score: 45, // Average of 9 per criterion
124
+ },
125
+ ];
126
+
127
+ const bestScores = calculateBestScores(submissions);
128
+
129
+ // First submission:
130
+ // Tests: (8/10) * 10 * 0.5 = 4
131
+ // Criteria: (32/(4*10)) * 10 * 0.5 = 4
132
+ // Total: 8
133
+
134
+ // Second submission:
135
+ // Tests: (6/10) * 10 * 0.5 = 3
136
+ // Criteria: (45/(5*10)) * 10 * 0.5 = 4.5
137
+ // Total: 7.5
138
+
139
+ expect(bestScores.get('p1')).toBe(8);
140
+ });
141
+
142
+ it('should skip submissions without problem_id', () => {
143
+ const submissions = [
144
+ { problem_id: 'p1', total_tests: 10, passed_tests: 5 },
145
+ { problem_id: '', total_tests: 10, passed_tests: 7 },
146
+ { problem_id: undefined as any, total_tests: 10, passed_tests: 3 },
147
+ ];
148
+
149
+ const bestScores = calculateBestScores(submissions);
150
+
151
+ expect(bestScores.get('p1')).toBe(5); // (5/10) * 10 * 1.0 = 5
152
+ expect(bestScores.size).toBe(1);
153
+ });
154
+
155
+ it('should return an empty map when no valid submissions exist', () => {
156
+ const submissions: Array<ScoreInput & { problem_id: string }> = [];
157
+
158
+ const bestScores = calculateBestScores(submissions);
159
+
160
+ expect(bestScores.size).toBe(0);
161
+ });
162
+ });
163
+
164
+ describe('aggregateByChallenge', () => {
165
+ it('should correctly aggregate scores by challenge', () => {
166
+ const problemScores = new Map([
167
+ ['p1', 5],
168
+ ['p2', 7],
169
+ ['p3', 3],
170
+ ]);
171
+
172
+ const problemChallengeMap = new Map([
173
+ ['p1', 'c1'],
174
+ ['p2', 'c1'],
175
+ ['p3', 'c2'],
176
+ ]);
177
+
178
+ const challengeScores = aggregateByChallenge(
179
+ problemScores,
180
+ problemChallengeMap
181
+ );
182
+
183
+ expect(challengeScores.c1).toBe(12); // 5 + 7
184
+ expect(challengeScores.c2).toBe(3);
185
+ });
186
+
187
+ it('should ignore problems that are not in the challenge map', () => {
188
+ const problemScores = new Map([
189
+ ['p1', 5],
190
+ ['p2', 7],
191
+ ['p3', 3],
192
+ ]);
193
+
194
+ const problemChallengeMap = new Map([
195
+ ['p1', 'c1'],
196
+ ['p3', 'c2'],
197
+ ]);
198
+
199
+ const challengeScores = aggregateByChallenge(
200
+ problemScores,
201
+ problemChallengeMap
202
+ );
203
+
204
+ expect(challengeScores.c1).toBe(5);
205
+ expect(challengeScores.c2).toBe(3);
206
+ expect(Object.keys(challengeScores).length).toBe(2);
207
+ });
208
+
209
+ it('should return an empty object when no problems match challenges', () => {
210
+ const problemScores = new Map([
211
+ ['p1', 5],
212
+ ['p2', 7],
213
+ ]);
214
+
215
+ const problemChallengeMap = new Map([
216
+ ['p3', 'c1'],
217
+ ['p4', 'c2'],
218
+ ]);
219
+
220
+ const challengeScores = aggregateByChallenge(
221
+ problemScores,
222
+ problemChallengeMap
223
+ );
224
+
225
+ expect(Object.keys(challengeScores).length).toBe(0);
226
+ });
227
+ });
228
+
229
+ describe('formatScore', () => {
230
+ it('should format score with default decimal places (1)', () => {
231
+ expect(formatScore(5)).toBe('5.0');
232
+ expect(formatScore(5.678)).toBe('5.7');
233
+ });
234
+
235
+ it('should format score with specified decimal places', () => {
236
+ expect(formatScore(5, 0)).toBe('5');
237
+ expect(formatScore(5.678, 2)).toBe('5.68');
238
+ expect(formatScore(5.678, 3)).toBe('5.678');
239
+ });
240
+ });
241
+
242
+ describe('calculatePercentage', () => {
243
+ it('should correctly calculate percentage', () => {
244
+ expect(calculatePercentage(5, 10)).toBe(50);
245
+ expect(calculatePercentage(7.5, 10)).toBe(75);
246
+ expect(calculatePercentage(0, 10)).toBe(0);
247
+ expect(calculatePercentage(10, 10)).toBe(100);
248
+ });
249
+
250
+ it('should return 0 when maxScore is 0 or negative', () => {
251
+ expect(calculatePercentage(5, 0)).toBe(0);
252
+ expect(calculatePercentage(5, -10)).toBe(0);
253
+ });
254
+ });