@musecat/functionkit 1.0.1 → 1.1.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 (130) hide show
  1. package/.agents/references/components/ScrolltoTop.md +36 -0
  2. package/.agents/references/components/SwitchCase.md +47 -0
  3. package/.agents/references/components/ViewportPortal.md +34 -0
  4. package/.agents/references/cookie/cookie.md +47 -0
  5. package/.agents/references/datetime/dateTime.client.md +42 -0
  6. package/.agents/references/datetime/dateTime.server.md +42 -0
  7. package/.agents/references/datetime/dateTime.shared.md +86 -0
  8. package/.agents/references/hooks/useAvoidKeyboard.md +29 -0
  9. package/.agents/references/hooks/useCheckInvisible.md +36 -0
  10. package/.agents/references/hooks/useCheckScroll.md +36 -0
  11. package/.agents/references/hooks/useClientDateTime.md +39 -0
  12. package/.agents/references/hooks/useDebounce.md +46 -0
  13. package/.agents/references/hooks/useDebouncedCallback.md +34 -0
  14. package/.agents/references/hooks/useDoubleClick.md +40 -0
  15. package/.agents/references/hooks/useGeolocation.md +37 -0
  16. package/.agents/references/hooks/useHasMounted.md +27 -0
  17. package/.agents/references/hooks/useIntersectionObserver.md +41 -0
  18. package/.agents/references/hooks/useInterval.md +44 -0
  19. package/.agents/references/hooks/useKeyboardHeight.md +33 -0
  20. package/.agents/references/hooks/useKeyboardListNavigation.md +58 -0
  21. package/.agents/references/hooks/useLongPress.md +48 -0
  22. package/.agents/references/hooks/usePreservedCallback.md +37 -0
  23. package/.agents/references/hooks/usePreservedReference.md +34 -0
  24. package/.agents/references/hooks/useRefEffect.md +35 -0
  25. package/.agents/references/hooks/useRelativeDateTime.md +35 -0
  26. package/.agents/references/hooks/useTimeout.md +42 -0
  27. package/.agents/references/hooks/useToggleState.md +39 -0
  28. package/.agents/references/hooks/useViewportHeight.md +26 -0
  29. package/.agents/references/hooks/useViewportMatch.md +32 -0
  30. package/.agents/references/utils/browserStorage.md +41 -0
  31. package/.agents/references/utils/buildContext.md +47 -0
  32. package/.agents/references/utils/clipboardShare.md +59 -0
  33. package/.agents/references/utils/floatingMotion.md +67 -0
  34. package/.agents/references/utils/getDeviceInfo.md +43 -0
  35. package/.agents/references/utils/isEditableKeyboardTarget.md +35 -0
  36. package/.agents/references/utils/mergeRefs.md +29 -0
  37. package/.agents/references/utils/seen.md +37 -0
  38. package/.agents/references/utils/subscribeKeyboardHeight.md +35 -0
  39. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  40. package/.github/ISSUE_TEMPLATE/feature_request.md +22 -0
  41. package/.github/PULL_REQUEST_TEMPLATE.md +22 -0
  42. package/.github/workflows/ci.yml +20 -0
  43. package/.local/state/gh/device-id +1 -0
  44. package/.prettierrc +8 -0
  45. package/AGENTS.md +117 -396
  46. package/CODE_OF_CONDUCT.md +55 -0
  47. package/CONTRIBUTING.md +39 -0
  48. package/README.md +18 -5
  49. package/biome.json +25 -0
  50. package/index.ts +63 -68
  51. package/package.json +46 -13
  52. package/packages/components/ScrolltoTop.tsx +3 -3
  53. package/packages/components/SwitchCase.tsx +7 -7
  54. package/packages/components/ViewportPortal.tsx +22 -23
  55. package/packages/cookie/cookie.shared.ts +80 -110
  56. package/packages/cookie/cookieNames.shared.ts +9 -0
  57. package/packages/datetime/dateTime.client.ts +84 -86
  58. package/packages/datetime/dateTime.server.ts +84 -86
  59. package/packages/datetime/dateTime.shared.ts +249 -287
  60. package/packages/hooks/useAvoidKeyboard.ts +15 -15
  61. package/packages/hooks/useCheckInvisible.ts +16 -16
  62. package/packages/hooks/useCheckScroll.ts +10 -10
  63. package/packages/hooks/useClientDateTime.ts +24 -37
  64. package/packages/hooks/useDebounce.ts +90 -95
  65. package/packages/hooks/useDebouncedCallback.ts +45 -45
  66. package/packages/hooks/useDoubleClick.ts +43 -43
  67. package/packages/hooks/useGeolocation.ts +126 -130
  68. package/packages/hooks/useHasMounted.ts +5 -5
  69. package/packages/hooks/useIntersectionObserver.ts +20 -20
  70. package/packages/hooks/useInterval.ts +45 -45
  71. package/packages/hooks/useKeyboardHeight.ts +9 -9
  72. package/packages/hooks/useKeyboardListNavigation.ts +130 -158
  73. package/packages/hooks/useLongPress.ts +87 -98
  74. package/packages/hooks/usePreservedCallback.ts +12 -12
  75. package/packages/hooks/usePreservedReference.ts +10 -10
  76. package/packages/hooks/useRefEffect.ts +19 -19
  77. package/packages/hooks/useRelativeDateTime.ts +36 -39
  78. package/packages/hooks/useTimeout.ts +41 -41
  79. package/packages/hooks/useToggleState.ts +5 -5
  80. package/packages/hooks/useViewportHeight.ts +12 -13
  81. package/packages/hooks/useViewportMatch.ts +13 -23
  82. package/packages/utils/browserStorage.ts +26 -26
  83. package/packages/utils/buildContext.tsx +10 -11
  84. package/packages/utils/checkDevice.ts +64 -64
  85. package/packages/utils/clipboardShare.tsx +31 -31
  86. package/packages/utils/clipboardShare.types.ts +7 -7
  87. package/packages/utils/floatingMotion.ts +71 -77
  88. package/packages/utils/keyboardTarget.ts +9 -9
  89. package/packages/utils/mergeRefs.ts +9 -9
  90. package/packages/utils/seen.ts +15 -15
  91. package/packages/utils/subscribeKeyboardHeight.ts +49 -46
  92. package/tests/components/ScrolltoTop.test.tsx +15 -0
  93. package/tests/components/SwitchCase.test.tsx +33 -0
  94. package/tests/components/ViewportPortal.test.tsx +46 -0
  95. package/tests/cookie/cookie.test.ts +192 -0
  96. package/tests/datetime/datetime.test.ts +461 -0
  97. package/tests/hooks/useAvoidKeyboard.test.ts +43 -0
  98. package/tests/hooks/useCheckInvisible.test.ts +59 -0
  99. package/tests/hooks/useCheckScroll.test.ts +23 -0
  100. package/tests/hooks/useClientDateTime.test.ts +12 -0
  101. package/tests/hooks/useDebounce.test.ts +96 -0
  102. package/tests/hooks/useDebouncedCallback.test.ts +63 -0
  103. package/tests/hooks/useDoubleClick.test.ts +82 -0
  104. package/tests/hooks/useGeolocation.test.ts +201 -0
  105. package/tests/hooks/useHasMounted.test.ts +10 -0
  106. package/tests/hooks/useIntersectionObserver.test.ts +78 -0
  107. package/tests/hooks/useInterval.test.ts +56 -0
  108. package/tests/hooks/useKeyboardHeight.test.ts +10 -0
  109. package/tests/hooks/useKeyboardListNavigation.test.ts +404 -0
  110. package/tests/hooks/useLongPress.test.ts +214 -0
  111. package/tests/hooks/usePreservedCallback.test.ts +33 -0
  112. package/tests/hooks/usePreservedReference.test.ts +42 -0
  113. package/tests/hooks/useRefEffect.test.ts +67 -0
  114. package/tests/hooks/useRelativeDateTime.test.ts +21 -0
  115. package/tests/hooks/useTimeout.test.ts +72 -0
  116. package/tests/hooks/useToggleState.test.ts +43 -0
  117. package/tests/hooks/useViewportHeight.test.ts +32 -0
  118. package/tests/hooks/useViewportMatch.test.ts +35 -0
  119. package/tests/setup.ts +9 -0
  120. package/tests/utils/browserStorage.test.ts +114 -0
  121. package/tests/utils/buildContext.test.tsx +26 -0
  122. package/tests/utils/checkDevice.test.ts +140 -0
  123. package/tests/utils/clipboardShare.test.ts +152 -0
  124. package/tests/utils/floatingMotion.test.ts +135 -0
  125. package/tests/utils/keyboardTarget.test.ts +85 -0
  126. package/tests/utils/mergeRefs.test.ts +50 -0
  127. package/tests/utils/seen.test.ts +40 -0
  128. package/tests/utils/subscribeKeyboardHeight.test.ts +172 -0
  129. package/tsconfig.json +16 -16
  130. package/vitest.config.ts +22 -0
@@ -0,0 +1,461 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import {
3
+ formatClientDate,
4
+ formatClientDateTime,
5
+ formatClientRelative,
6
+ formatClientTime,
7
+ } from "@/packages/datetime/dateTime.client";
8
+ import {
9
+ formatServerDate,
10
+ formatServerDateTime,
11
+ formatServerRelative,
12
+ formatServerTime,
13
+ } from "@/packages/datetime/dateTime.server";
14
+ import {
15
+ addUtcDays,
16
+ format24HourTime,
17
+ formatDotDate,
18
+ formatKoreanTime,
19
+ formatLongDate,
20
+ formatRelativeText,
21
+ formatRemainingText,
22
+ formatTwelveHourTime,
23
+ formatUtcDateKey,
24
+ getUtcWeekdayIndex,
25
+ normalizeAppLocale,
26
+ parseUtcDateInput,
27
+ toDate,
28
+ toIntlLocale,
29
+ toUtcMidnight,
30
+ } from "@/packages/datetime/dateTime.shared";
31
+
32
+ describe("datetime module", () => {
33
+ describe("shared", () => {
34
+ describe("normalizeAppLocale", () => {
35
+ test("normalizes kr/ko", () => {
36
+ expect(normalizeAppLocale("kr")).toBe("kr");
37
+ expect(normalizeAppLocale("ko")).toBe("kr");
38
+ });
39
+ test("normalizes jp/ja", () => {
40
+ expect(normalizeAppLocale("jp")).toBe("jp");
41
+ expect(normalizeAppLocale("ja")).toBe("jp");
42
+ });
43
+ test("defaults to en", () => {
44
+ expect(normalizeAppLocale("fr")).toBe("en");
45
+ expect(normalizeAppLocale(undefined)).toBe("en");
46
+ });
47
+ });
48
+
49
+ describe("toIntlLocale", () => {
50
+ test("maps to correct intl locales", () => {
51
+ expect(toIntlLocale("kr")).toBe("ko-KR");
52
+ expect(toIntlLocale("jp")).toBe("ja-JP");
53
+ expect(toIntlLocale("en")).toBe("en-US");
54
+ });
55
+ });
56
+
57
+ describe("toDate", () => {
58
+ test("returns Date object for valid inputs", () => {
59
+ expect(toDate(new Date("2020-01-01"))).toBeInstanceOf(Date);
60
+ expect(toDate("2020-01-01")).toBeInstanceOf(Date);
61
+ expect(toDate(1577836800000)).toBeInstanceOf(Date);
62
+ });
63
+ test("returns null for invalid inputs", () => {
64
+ expect(toDate("invalid")).toBeNull();
65
+ expect(toDate(new Date("invalid"))).toBeNull();
66
+ });
67
+ });
68
+
69
+ describe("toUtcMidnight", () => {
70
+ test("converts to UTC midnight", () => {
71
+ const date = new Date("2020-01-01T15:30:00Z");
72
+ const utcDate = toUtcMidnight(date);
73
+ expect(utcDate.toISOString()).toBe("2020-01-01T00:00:00.000Z");
74
+ });
75
+ });
76
+
77
+ describe("parseUtcDateInput", () => {
78
+ test("parses YYYY-MM-DD string to UTC midnight", () => {
79
+ const result = parseUtcDateInput("2020-05-10");
80
+ expect(result?.toISOString()).toBe("2020-05-10T00:00:00.000Z");
81
+ });
82
+ test("parses Date object to UTC midnight", () => {
83
+ const result = parseUtcDateInput(new Date("2020-05-10T15:00:00Z"));
84
+ expect(result?.toISOString()).toBe("2020-05-10T00:00:00.000Z");
85
+ });
86
+ test("uses fallback", () => {
87
+ const result = parseUtcDateInput("invalid", "2020-01-01T00:00:00Z");
88
+ expect(result?.toISOString()).toBe("2020-01-01T00:00:00.000Z");
89
+ });
90
+ test("returns null if both invalid", () => {
91
+ expect(parseUtcDateInput("invalid", "invalid")).toBeNull();
92
+ });
93
+
94
+ test("returns null when no fallback provided", () => {
95
+ expect(parseUtcDateInput("invalid")).toBeNull();
96
+ });
97
+
98
+ test("handles undefined value", () => {
99
+ expect(parseUtcDateInput(undefined)).toBeNull();
100
+ });
101
+
102
+ test("handles null value", () => {
103
+ expect(parseUtcDateInput(null)).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe("formatUtcDateKey", () => {
108
+ test("formats date to YYYY-MM-DD", () => {
109
+ expect(formatUtcDateKey(new Date("2020-05-05T00:00:00Z"))).toBe("2020-05-05");
110
+ });
111
+ });
112
+
113
+ describe("getUtcWeekdayIndex", () => {
114
+ test("returns correct weekday index", () => {
115
+ expect(getUtcWeekdayIndex(new Date("2020-01-05T00:00:00Z"))).toBe(0); // Sunday
116
+ expect(getUtcWeekdayIndex(new Date("2020-01-06T00:00:00Z"))).toBe(1); // Monday
117
+ });
118
+ });
119
+
120
+ describe("addUtcDays", () => {
121
+ test("adds days to date", () => {
122
+ const result = addUtcDays(new Date("2020-01-01T00:00:00Z"), 5);
123
+ expect(result.toISOString()).toBe("2020-01-06T00:00:00.000Z");
124
+ });
125
+ test("handles negative days", () => {
126
+ const result = addUtcDays(new Date("2020-01-05T00:00:00Z"), -2);
127
+ expect(result.toISOString()).toBe("2020-01-03T00:00:00.000Z");
128
+ });
129
+ });
130
+
131
+ describe("formatters", () => {
132
+ const testDate = new Date("2020-05-10T15:30:45Z"); // UTC time
133
+
134
+ test("formatLongDate", () => {
135
+ expect(formatLongDate(testDate, "kr", "UTC")).toBe("2020년 5월 10일");
136
+ expect(formatLongDate(testDate, "jp", "UTC")).toBe("2020年5月10日");
137
+ expect(formatLongDate(testDate, "en", "UTC")).toContain("2020");
138
+ });
139
+
140
+ test("handles missing date/time parts gracefully", () => {
141
+ const spy = vi.spyOn(Intl.DateTimeFormat.prototype, "formatToParts").mockReturnValue([]);
142
+ try {
143
+ expect(formatLongDate(testDate, "kr", "UTC")).toBe("0000년 0월 0일");
144
+ expect(format24HourTime(testDate)).toBe("00:00");
145
+ } finally {
146
+ spy.mockRestore();
147
+ }
148
+ });
149
+
150
+ test("formatDotDate", () => {
151
+ expect(formatDotDate(testDate, "UTC")).toBe("2020. 5. 10.");
152
+ });
153
+
154
+ test("format24HourTime", () => {
155
+ expect(format24HourTime(testDate, { timeZone: "UTC" })).toBe("15:30");
156
+ expect(format24HourTime(testDate, { includeSeconds: true, timeZone: "UTC" })).toBe(
157
+ "15:30:45",
158
+ );
159
+ expect(format24HourTime(testDate)).toBe("15:30");
160
+ });
161
+
162
+ test("formatKoreanTime", () => {
163
+ expect(formatKoreanTime(testDate, "UTC")).toBe("오후 3시 30분");
164
+ expect(formatKoreanTime(new Date("2020-05-10T09:15:00Z"), "UTC")).toBe("오전 9시 15분");
165
+ expect(formatKoreanTime(new Date("2020-05-10T00:30:00Z"), "UTC")).toBe("오전 12시 30분");
166
+ expect(formatKoreanTime(new Date("2020-05-10T12:30:00Z"), "UTC")).toBe("오후 12시 30분");
167
+ });
168
+
169
+ test("formatTwelveHourTime", () => {
170
+ expect(formatTwelveHourTime(testDate, "kr", "UTC")).toBe("오후 3:30");
171
+ expect(formatTwelveHourTime(new Date("2020-05-10T00:30:00Z"), "kr", "UTC")).toBe("오전 12:30");
172
+ expect(formatTwelveHourTime(new Date("2020-05-10T12:30:00Z"), "kr", "UTC")).toBe("오후 12:30");
173
+ expect(formatTwelveHourTime(testDate, "en", "UTC")).toMatch(/3:30 (PM|pm)/);
174
+ });
175
+ });
176
+
177
+ describe("formatRelativeText", () => {
178
+ test("formatRelativeUnit with singular count (1 second, 1 minute, etc)", () => {
179
+ expect(formatRelativeText(1000, "en")).toEqual({
180
+ text: "1 second ago",
181
+ isRelative: true,
182
+ });
183
+ expect(formatRelativeText(60000, "en")).toEqual({
184
+ text: "1 minute ago",
185
+ isRelative: true,
186
+ });
187
+ expect(formatRelativeText(3600000, "en")).toEqual({
188
+ text: "1 hour ago",
189
+ isRelative: true,
190
+ });
191
+ expect(formatRelativeText(86400000, "en")).toEqual({
192
+ text: "1 day ago",
193
+ isRelative: true,
194
+ });
195
+ });
196
+
197
+ test("formats seconds", () => {
198
+ expect(formatRelativeText(30000, "en")).toEqual({
199
+ text: "30 seconds ago",
200
+ isRelative: true,
201
+ });
202
+ expect(formatRelativeText(30000, "kr")).toEqual({
203
+ text: "30초 전",
204
+ isRelative: true,
205
+ });
206
+ expect(formatRelativeText(30000, "jp")).toEqual({
207
+ text: "30秒前",
208
+ isRelative: true,
209
+ });
210
+ });
211
+ test("formats minutes", () => {
212
+ expect(formatRelativeText(120000, "en")).toEqual({
213
+ text: "2 minutes ago",
214
+ isRelative: true,
215
+ });
216
+ expect(formatRelativeText(120000, "kr")).toEqual({
217
+ text: "2분 전",
218
+ isRelative: true,
219
+ });
220
+ expect(formatRelativeText(120000, "jp")).toEqual({
221
+ text: "2分前",
222
+ isRelative: true,
223
+ });
224
+ });
225
+ test("formats hours", () => {
226
+ expect(formatRelativeText(7200000, "en")).toEqual({
227
+ text: "2 hours ago",
228
+ isRelative: true,
229
+ });
230
+ expect(formatRelativeText(7200000, "kr")).toEqual({
231
+ text: "2시간 전",
232
+ isRelative: true,
233
+ });
234
+ expect(formatRelativeText(7200000, "jp")).toEqual({
235
+ text: "2時間前",
236
+ isRelative: true,
237
+ });
238
+ });
239
+ test("formats days", () => {
240
+ expect(formatRelativeText(172800000, "en")).toEqual({
241
+ text: "2 days ago",
242
+ isRelative: true,
243
+ });
244
+ expect(formatRelativeText(172800000, "kr")).toEqual({
245
+ text: "2일 전",
246
+ isRelative: true,
247
+ });
248
+ expect(formatRelativeText(172800000, "jp")).toEqual({
249
+ text: "2日前",
250
+ isRelative: true,
251
+ });
252
+ });
253
+ });
254
+
255
+ describe("formatRemainingText", () => {
256
+ test("formats remaining time", () => {
257
+ expect(formatRemainingText(0)).toBe("Closed");
258
+ expect(formatRemainingText(0, "kr")).toBe("마감됨");
259
+ expect(formatRemainingText(0, "jp")).toBe("締切終了");
260
+
261
+ expect(formatRemainingText(1000, "en")).toBe("1 second left");
262
+ expect(formatRemainingText(65000, "en")).toBe("1 minute 5 seconds left");
263
+ expect(formatRemainingText(3665000, "kr")).toBe("1시간 1분 남음");
264
+ expect(formatRemainingText(90065000, "jp")).toBe("あと1日1時間");
265
+ });
266
+ test("without suffix", () => {
267
+ expect(formatRemainingText(1000, "en", { includeSuffix: false })).toBe("1 second");
268
+ });
269
+
270
+ test("remaining time less than 1 second defaults to 1 second", () => {
271
+ expect(formatRemainingText(500, "en")).toBe("1 second left");
272
+ });
273
+
274
+ test("formatRemainingText jp locale with no suffix", () => {
275
+ expect(formatRemainingText(3665000, "jp", { includeSuffix: false })).toBe("1時間1分");
276
+ });
277
+
278
+ test("formatRemainingUnit for kr locale covers all units", () => {
279
+ expect(formatRemainingText(1000, "kr")).toBe("1초 남음");
280
+ expect(formatRemainingText(60000, "kr")).toBe("1분 남음");
281
+ expect(formatRemainingText(3600000, "kr")).toBe("1시간 남음");
282
+ expect(formatRemainingText(86400000, "kr")).toBe("1일 남음");
283
+ });
284
+
285
+ test("formatRemainingUnit for jp locale", () => {
286
+ expect(formatRemainingText(1000, "jp")).toBe("あと1秒");
287
+ expect(formatRemainingText(60000, "jp")).toBe("あと1分");
288
+ expect(formatRemainingText(3600000, "jp")).toBe("あと1時間");
289
+ expect(formatRemainingText(86400000, "jp")).toBe("あと1日");
290
+ });
291
+
292
+ test("formatRemainingUnit for en locale covers all units", () => {
293
+ expect(formatRemainingText(1000, "en")).toBe("1 second left");
294
+ expect(formatRemainingText(60000, "en")).toBe("1 minute left");
295
+ expect(formatRemainingText(3600000, "en")).toBe("1 hour left");
296
+ expect(formatRemainingText(86400000, "en")).toBe("1 day left");
297
+ });
298
+
299
+ test("formatLongDate with undefined locale defaults to en", () => {
300
+ expect(formatLongDate(new Date("2020-05-10T15:00:00Z"), undefined)).toContain("2020");
301
+ });
302
+
303
+ test("formatRelativeText with jp locale", () => {
304
+ expect(formatRelativeText(30000, "jp")).toEqual({
305
+ text: "30秒前",
306
+ isRelative: true,
307
+ });
308
+ expect(formatRelativeText(120000, "jp")).toEqual({
309
+ text: "2分前",
310
+ isRelative: true,
311
+ });
312
+ expect(formatRelativeText(7200000, "jp")).toEqual({
313
+ text: "2時間前",
314
+ isRelative: true,
315
+ });
316
+ expect(formatRelativeText(172800000, "jp")).toEqual({
317
+ text: "2日前",
318
+ isRelative: true,
319
+ });
320
+ });
321
+ });
322
+ });
323
+
324
+ describe("client/server wrappers", () => {
325
+ const testDate = new Date("2020-05-10T15:30:45Z");
326
+
327
+ test("formatClientDateTime / formatServerDateTime", () => {
328
+ expect(formatClientDateTime(testDate, { timeZone: "UTC" })).toBe("May 10, 2020 15:30");
329
+ expect(formatClientDateTime(testDate, { locale: "kr", timeZone: "UTC" })).toBe(
330
+ "2020년 5월 10일 15:30",
331
+ );
332
+ expect(formatServerDateTime(testDate, { locale: "kr", timeZone: "UTC" })).toBe(
333
+ "2020년 5월 10일 15:30",
334
+ );
335
+ });
336
+
337
+ test("formatClientTime with various presets", () => {
338
+ expect(formatClientTime(testDate, { preset: "ko", timeZone: "UTC" })).toBe("오후 3시 30분");
339
+ expect(formatClientTime(testDate, { preset: "12h", locale: "en", timeZone: "UTC" })).toMatch(
340
+ /3:30 (PM|pm)/,
341
+ );
342
+ expect(formatClientTime(testDate, { preset: "24h-second", timeZone: "UTC" })).toBe(
343
+ "15:30:45",
344
+ );
345
+ });
346
+
347
+ test("formatServerTime with various presets", () => {
348
+ expect(formatServerTime(testDate, { preset: "ko", timeZone: "UTC" })).toBe("오후 3시 30분");
349
+ expect(formatServerTime(testDate, { preset: "12h", locale: "en", timeZone: "UTC" })).toMatch(
350
+ /3:30 (PM|pm)/,
351
+ );
352
+ expect(formatServerTime(testDate, { preset: "24h-second", timeZone: "UTC" })).toBe(
353
+ "15:30:45",
354
+ );
355
+ });
356
+
357
+ test("formatClientTime returns empty for invalid date", () => {
358
+ expect(formatClientTime("invalid", { preset: "24h-minute" })).toBe("");
359
+ });
360
+
361
+ test("formatClientDateTime returns only date or time when one is empty", () => {
362
+ expect(formatClientDateTime("invalid", {})).toBe("");
363
+ });
364
+
365
+ test("formatClientRelative / formatServerRelative", () => {
366
+ const now = new Date("2020-05-10T15:35:45Z"); // 5 mins later
367
+ expect(formatClientRelative(testDate, { now, locale: "kr" })).toEqual({
368
+ text: "5분 전",
369
+ isRelative: true,
370
+ });
371
+ expect(formatServerRelative(testDate, { now, locale: "kr" })).toEqual({
372
+ text: "5분 전",
373
+ isRelative: true,
374
+ });
375
+
376
+ const farFutureNow = new Date("2020-05-20T15:30:45Z"); // 10 days later
377
+ expect(
378
+ formatClientRelative(testDate, {
379
+ now: farFutureNow,
380
+ locale: "kr",
381
+ fallbackDatePreset: "dot",
382
+ maxRelativeDays: 7,
383
+ }),
384
+ ).toEqual({ text: formatDotDate(testDate), isRelative: false });
385
+ });
386
+
387
+ test("formatClientRelative returns empty when given invalid date", () => {
388
+ expect(formatClientRelative("invalid", {})).toEqual({ text: "", isRelative: false });
389
+ });
390
+
391
+ test("formatClientRelative beyond maxRelative without fallbackDatePreset uses dot default", () => {
392
+ const oldDate = new Date("2020-01-01T00:00:00Z");
393
+ const now = new Date("2020-05-10T00:00:00Z");
394
+ const result = formatClientRelative(oldDate, { now, maxRelativeDays: 30, locale: "en" });
395
+ expect(result.isRelative).toBe(false);
396
+ expect(result.text).toContain("2020");
397
+ });
398
+
399
+ test("formatClientRelative returns empty when now is invalid", () => {
400
+ expect(formatClientRelative(testDate, { now: "invalid" })).toEqual({
401
+ text: "",
402
+ isRelative: false,
403
+ });
404
+ });
405
+
406
+ test("formatServerRelative returns empty when given invalid date", () => {
407
+ expect(formatServerRelative("invalid", {})).toEqual({ text: "", isRelative: false });
408
+ });
409
+
410
+ test("formatServerRelative returns empty when now is invalid", () => {
411
+ expect(formatServerRelative(testDate, { now: "invalid" })).toEqual({
412
+ text: "",
413
+ isRelative: false,
414
+ });
415
+ });
416
+
417
+ test("formatServerRelative beyond maxRelativeDays with fallback preset", () => {
418
+ const oldDate = new Date("2020-01-01T00:00:00Z");
419
+ const now = new Date("2020-05-10T00:00:00Z");
420
+ const result = formatServerRelative(oldDate, {
421
+ now,
422
+ maxRelativeDays: 30,
423
+ fallbackDatePreset: "long",
424
+ locale: "en",
425
+ });
426
+ expect(result.isRelative).toBe(false);
427
+ expect(typeof result.text).toBe("string");
428
+ });
429
+
430
+ test("formatServerRelative beyond maxRelative without fallbackDatePreset", () => {
431
+ const oldDate = new Date("2020-01-01T00:00:00Z");
432
+ const now = new Date("2020-05-10T00:00:00Z");
433
+ const result = formatServerRelative(oldDate, { now, maxRelativeDays: 30, locale: "en" });
434
+ expect(result.isRelative).toBe(false);
435
+ expect(result.text).toContain("2020");
436
+ });
437
+
438
+ test("formatServerDate returns empty for invalid date", () => {
439
+ expect(formatServerDate("invalid", { preset: "dot" })).toBe("");
440
+ });
441
+
442
+ test("formatServerDate with dot preset", () => {
443
+ expect(formatServerDate(testDate, { preset: "dot", timeZone: "UTC" })).toBe("2020. 5. 10.");
444
+ });
445
+
446
+ test("formatServerDate with long preset", () => {
447
+ expect(formatServerDate(testDate, { preset: "long", locale: "kr", timeZone: "UTC" })).toBe(
448
+ "2020년 5월 10일",
449
+ );
450
+ });
451
+
452
+ test("formatServerDateTime returns only date when time is empty", () => {
453
+ expect(formatServerDateTime("invalid", {})).toBe("");
454
+ });
455
+
456
+ test("handles invalid inputs gracefully", () => {
457
+ expect(formatClientDate("invalid", { preset: "long" })).toBe("");
458
+ expect(formatServerTime("invalid", { preset: "ko" })).toBe("");
459
+ });
460
+ });
461
+ });
@@ -0,0 +1,43 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, test } from "vitest";
3
+ import { useAvoidKeyboard } from "@/packages/hooks/useAvoidKeyboard";
4
+
5
+ import { useKeyboardHeight } from "@/packages/hooks/useKeyboardHeight";
6
+
7
+ vi.mock("@/packages/hooks/useKeyboardHeight");
8
+
9
+ describe("useAvoidKeyboard", () => {
10
+ beforeEach(() => {
11
+ vi.mocked(useKeyboardHeight).mockReturnValue({ keyboardHeight: 0 });
12
+ });
13
+
14
+ test("returns style object", () => {
15
+ const { result } = renderHook(() => useAvoidKeyboard());
16
+ expect(result.current.style).toBeDefined();
17
+ expect(result.current.style.transform).toBeUndefined();
18
+ expect(result.current.style.transition).toContain("transform");
19
+ });
20
+
21
+ test("applies translateY when keyboard is raised", () => {
22
+ vi.mocked(useKeyboardHeight).mockReturnValue({ keyboardHeight: 300 });
23
+ const { result } = renderHook(() => useAvoidKeyboard());
24
+ expect(result.current.style.transform).toBe("translateY(-300px)");
25
+ });
26
+
27
+ test("uses safeAreaBottom when keyboardHeight is 0", () => {
28
+ const { result } = renderHook(() => useAvoidKeyboard({ safeAreaBottom: 20 }));
29
+ expect(result.current.style.transform).toBe("translateY(-20px)");
30
+ });
31
+
32
+ test("no transform when both keyboardHeight and safeAreaBottom are 0", () => {
33
+ const { result } = renderHook(() => useAvoidKeyboard());
34
+ expect(result.current.style.transform).toBeUndefined();
35
+ });
36
+
37
+ test("accepts custom transition values", () => {
38
+ const { result } = renderHook(() =>
39
+ useAvoidKeyboard({ transitionDuration: 300, transitionTimingFunction: "linear" }),
40
+ );
41
+ expect(result.current.style.transition).toBe("transform 300ms linear");
42
+ });
43
+ });
@@ -0,0 +1,59 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import { useCheckInvisible } from "@/packages/hooks/useCheckInvisible";
4
+
5
+ describe("useCheckInvisible", () => {
6
+ test("returns isInvisible false when element is visible", () => {
7
+ const el = document.createElement("div");
8
+ el.className = "test-section";
9
+ el.getBoundingClientRect = vi.fn(
10
+ () =>
11
+ ({
12
+ top: 100,
13
+ bottom: 300,
14
+ left: 0,
15
+ right: 100,
16
+ width: 100,
17
+ height: 200,
18
+ x: 0,
19
+ y: 100,
20
+ }) as DOMRect,
21
+ );
22
+ document.body.appendChild(el);
23
+
24
+ const { result } = renderHook(() => useCheckInvisible("test-section"));
25
+ expect(result.current.isInvisible).toBe(false);
26
+ document.body.removeChild(el);
27
+ });
28
+
29
+ test("returns isInvisible false when element is not found in DOM", () => {
30
+ const { result } = renderHook(() => useCheckInvisible("non-existent"));
31
+ expect(result.current.isInvisible).toBe(false);
32
+ });
33
+
34
+ test("returns isInvisible true when element is scrolled above viewport", () => {
35
+ const el = document.createElement("div");
36
+ el.className = "test-section";
37
+ el.getBoundingClientRect = vi.fn(
38
+ () =>
39
+ ({
40
+ top: -50,
41
+ bottom: 150,
42
+ left: 0,
43
+ right: 100,
44
+ width: 100,
45
+ height: 200,
46
+ x: 0,
47
+ y: -50,
48
+ }) as DOMRect,
49
+ );
50
+ document.body.appendChild(el);
51
+
52
+ const { result } = renderHook(() => useCheckInvisible("test-section"));
53
+ act(() => {
54
+ window.dispatchEvent(new Event("scroll"));
55
+ });
56
+ expect(result.current.isInvisible).toBe(true);
57
+ document.body.removeChild(el);
58
+ });
59
+ });
@@ -0,0 +1,23 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, test } from "vitest";
3
+ import { useCheckScroll } from "@/packages/hooks/useCheckScroll";
4
+
5
+ describe("useCheckScroll", () => {
6
+ test("initializes with scrollY state", () => {
7
+ Object.defineProperty(window, "scrollY", { value: 100, writable: true });
8
+ const { result } = renderHook(() => useCheckScroll());
9
+ expect(result.current.isScrolled).toBe(true);
10
+ });
11
+
12
+ test("updates state on scroll", () => {
13
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true });
14
+ const { result } = renderHook(() => useCheckScroll());
15
+ expect(result.current.isScrolled).toBe(false);
16
+
17
+ act(() => {
18
+ window.scrollY = 150;
19
+ window.dispatchEvent(new Event("scroll"));
20
+ });
21
+ expect(result.current.isScrolled).toBe(true);
22
+ });
23
+ });
@@ -0,0 +1,12 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, test } from "vitest";
3
+ import { useClientDateTime } from "@/packages/hooks/useClientDateTime";
4
+
5
+ describe("useClientDateTime", () => {
6
+ test("returns ready state and formatted text", () => {
7
+ const { result } = renderHook(() => useClientDateTime("2024-06-15", { locale: "ko" }));
8
+ expect(result.current.ready).toBe(true);
9
+ expect(result.current.text).toBeTruthy();
10
+ expect(result.current.date).toBeInstanceOf(Date);
11
+ });
12
+ });
@@ -0,0 +1,96 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
+ import { debounce, useDebounce } from "@/packages/hooks/useDebounce";
4
+
5
+ describe("debounce (standalone)", () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ test("trailing: calls function after delay", () => {
14
+ const fn = vi.fn();
15
+ const d = debounce(fn, 100, { edges: ["trailing"] });
16
+ d();
17
+ expect(fn).not.toHaveBeenCalled();
18
+ vi.advanceTimersByTime(100);
19
+ expect(fn).toHaveBeenCalledOnce();
20
+ });
21
+
22
+ test("leading + trailing: calls immediately and debounces trailing", () => {
23
+ const fn = vi.fn();
24
+ const d = debounce(fn, 100, { edges: ["leading", "trailing"] });
25
+ d();
26
+ expect(fn).toHaveBeenCalledTimes(1);
27
+ d();
28
+ vi.advanceTimersByTime(100);
29
+ expect(fn).toHaveBeenCalledTimes(2);
30
+ });
31
+
32
+ test("leading only: calls immediately, no trailing", () => {
33
+ const fn = vi.fn();
34
+ const d = debounce(fn, 100, { edges: ["leading"] });
35
+ d();
36
+ expect(fn).toHaveBeenCalledTimes(1);
37
+ vi.advanceTimersByTime(100);
38
+ expect(fn).toHaveBeenCalledTimes(1);
39
+ });
40
+
41
+ test("cancel aborts pending call", () => {
42
+ const fn = vi.fn();
43
+ const d = debounce(fn, 100, { edges: ["trailing"] });
44
+ d();
45
+ d.cancel();
46
+ vi.advanceTimersByTime(100);
47
+ expect(fn).not.toHaveBeenCalled();
48
+ });
49
+
50
+ test("multiple calls reset the timer in trailing mode", () => {
51
+ const fn = vi.fn();
52
+ const d = debounce(fn, 100, { edges: ["trailing"] });
53
+ d();
54
+ vi.advanceTimersByTime(50);
55
+ d();
56
+ vi.advanceTimersByTime(50);
57
+ expect(fn).not.toHaveBeenCalled();
58
+ vi.advanceTimersByTime(50);
59
+ expect(fn).toHaveBeenCalledOnce();
60
+ });
61
+ });
62
+
63
+ describe("useDebounce", () => {
64
+ beforeEach(() => {
65
+ vi.useFakeTimers();
66
+ });
67
+ afterEach(() => {
68
+ vi.useRealTimers();
69
+ });
70
+
71
+ test("returns a debounced function", () => {
72
+ const fn = vi.fn();
73
+ const { result } = renderHook(() => useDebounce(fn, 100));
74
+ result.current();
75
+ expect(fn).not.toHaveBeenCalled();
76
+ vi.advanceTimersByTime(100);
77
+ expect(fn).toHaveBeenCalledOnce();
78
+ });
79
+
80
+ test("works with leading: true", () => {
81
+ const fn = vi.fn();
82
+ const { result } = renderHook(() => useDebounce(fn, 100, { leading: true }));
83
+ result.current();
84
+ expect(fn).toHaveBeenCalledOnce();
85
+ vi.advanceTimersByTime(100);
86
+ expect(fn).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ test("works with trailing: false", () => {
90
+ const fn = vi.fn();
91
+ const { result } = renderHook(() => useDebounce(fn, 100, { trailing: false }));
92
+ result.current();
93
+ vi.advanceTimersByTime(100);
94
+ expect(fn).not.toHaveBeenCalled();
95
+ });
96
+ });