@ncds/ui-admin 1.8.17 → 1.8.18

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 (21) hide show
  1. package/dist/cjs/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
  2. package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
  3. package/dist/cjs/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  4. package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
  5. package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +249 -0
  6. package/dist/cjs/vitest.config.js +23 -1
  7. package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
  8. package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
  9. package/dist/esm/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  10. package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
  11. package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +250 -1
  12. package/dist/esm/vitest.config.js +22 -1
  13. package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +184 -36
  14. package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +323 -0
  15. package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
  16. package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  17. package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +40 -0
  18. package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +190 -1
  19. package/dist/temp/vitest.config.js +24 -0
  20. package/dist/types/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
  21. package/package.json +5 -3
@@ -107,3 +107,326 @@ describe('#3 onValidationError.previousDate', () => {
107
107
  expect(arg.previousDate?.getTime()).not.toBe(arg.date.getTime());
108
108
  });
109
109
  });
110
+ describe('#5 blur 동기화 — flatpickr가 통지하지 않은 값 보정 (#410)', () => {
111
+ const getInstance = (container) => {
112
+ const input = container.querySelector('input');
113
+ expect(input._flatpickr).toBeTruthy();
114
+ return { input, instance: input._flatpickr };
115
+ };
116
+ /** blur 동기화는 한 틱 뒤에 실행되므로 매크로태스크를 한 번 흘려보낸다 */
117
+ const flushBlurSync = async () => {
118
+ await act(async () => {
119
+ await new Promise((resolve) => setTimeout(resolve, 0));
120
+ });
121
+ };
122
+ it('onChange 없이 값만 반영된 채 blur 되면 onChangeDate 로 통지한다', async () => {
123
+ const onChangeDate = vi.fn();
124
+ const container = mount({
125
+ currentDate: '2024-03-01',
126
+ onChangeDate,
127
+ datePickerOptions: { allowInput: true },
128
+ });
129
+ const { input, instance } = getInstance(container);
130
+ // flatpickr documentClick 이 하는 것과 동일하게, onChange 를 발화시키지 않고 값만 반영한다
131
+ act(() => {
132
+ instance.setDate('2024-03-20', false);
133
+ });
134
+ onChangeDate.mockClear();
135
+ act(() => {
136
+ input.dispatchEvent(new Event('blur'));
137
+ });
138
+ await flushBlurSync();
139
+ expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
140
+ });
141
+ it('flatpickr가 이미 onChange 로 통지했으면 blur 가 중복 통지하지 않는다', async () => {
142
+ const onChangeDate = vi.fn();
143
+ const container = mount({
144
+ currentDate: '2024-03-01',
145
+ onChangeDate,
146
+ datePickerOptions: { allowInput: true },
147
+ });
148
+ const { input, instance } = getInstance(container);
149
+ act(() => {
150
+ instance.setDate('2024-03-20', true);
151
+ });
152
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
153
+ act(() => {
154
+ input.dispatchEvent(new Event('blur'));
155
+ });
156
+ await flushBlurSync();
157
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
158
+ });
159
+ it('min/max 위반 값은 blur 로도 통지하지 않고 onValidationError 로 보고한다', async () => {
160
+ const onChangeDate = vi.fn();
161
+ const onValidationError = vi.fn();
162
+ const container = mount({
163
+ currentDate: '2024-03-15',
164
+ onChangeDate,
165
+ onValidationError,
166
+ datePickerOptions: { minDate: '2024-03-10', maxDate: '2024-03-25', allowInput: true },
167
+ });
168
+ const { input, instance } = getInstance(container);
169
+ // 범위 밖 날짜가 onChange 없이 값만 반영된 상태 (마우스로 캘린더 밖 클릭 시 flatpickr 동작)
170
+ act(() => {
171
+ instance.setDate('2024-04-30', false);
172
+ });
173
+ onChangeDate.mockClear();
174
+ onValidationError.mockClear();
175
+ act(() => {
176
+ input.dispatchEvent(new Event('blur'));
177
+ });
178
+ await flushBlurSync();
179
+ expect(onChangeDate).not.toHaveBeenCalled();
180
+ expect(onValidationError).toHaveBeenCalledTimes(1);
181
+ expect(onValidationError.mock.calls[0][0].violations).toContain('maxDate');
182
+ });
183
+ it('enableTime 모드에서 값 변경 없이 blur 해도 통지하지 않는다', async () => {
184
+ const onChangeDate = vi.fn();
185
+ const container = mount({
186
+ currentDate: '2024-03-20',
187
+ onChangeDate,
188
+ datePickerOptions: { enableTime: true, dateFormat: 'Y-m-d H:i', allowInput: true },
189
+ });
190
+ const { input } = getInstance(container);
191
+ onChangeDate.mockClear();
192
+ act(() => {
193
+ input.dispatchEvent(new Event('blur'));
194
+ });
195
+ await flushBlurSync();
196
+ expect(onChangeDate).not.toHaveBeenCalled();
197
+ });
198
+ it('noCalendar(시간 전용) 모드에서 값 변경 없이 blur 해도 통지하지 않는다', async () => {
199
+ const onChangeDate = vi.fn();
200
+ const container = mount({
201
+ currentDate: '',
202
+ onChangeDate,
203
+ datePickerOptions: { enableTime: true, noCalendar: true, dateFormat: 'H:i', allowInput: true },
204
+ });
205
+ const { input } = getInstance(container);
206
+ onChangeDate.mockClear();
207
+ act(() => {
208
+ input.dispatchEvent(new Event('blur'));
209
+ });
210
+ await flushBlurSync();
211
+ expect(onChangeDate).not.toHaveBeenCalled();
212
+ });
213
+ it('noCalendar(시간 전용) 모드에서 통지 없이 시간만 바뀐 채 blur 되면 통지한다', async () => {
214
+ const onChangeDate = vi.fn();
215
+ const container = mount({
216
+ currentDate: '',
217
+ onChangeDate,
218
+ datePickerOptions: { enableTime: true, noCalendar: true, dateFormat: 'H:i', allowInput: true },
219
+ });
220
+ const { input, instance } = getInstance(container);
221
+ act(() => {
222
+ instance.setDate('14:30', false);
223
+ });
224
+ onChangeDate.mockClear();
225
+ act(() => {
226
+ input.dispatchEvent(new Event('blur'));
227
+ });
228
+ await flushBlurSync();
229
+ expect(onChangeDate).toHaveBeenCalledWith('14:30');
230
+ });
231
+ it('portal 모드에서도 통지 없이 값만 반영된 채 blur 되면 통지한다', async () => {
232
+ const onChangeDate = vi.fn();
233
+ const container = mount({
234
+ currentDate: '2024-03-01',
235
+ onChangeDate,
236
+ portal: true,
237
+ datePickerOptions: { allowInput: true },
238
+ });
239
+ const { input, instance } = getInstance(container);
240
+ act(() => {
241
+ instance.setDate('2024-03-20', false);
242
+ });
243
+ onChangeDate.mockClear();
244
+ act(() => {
245
+ input.dispatchEvent(new Event('blur'));
246
+ });
247
+ await flushBlurSync();
248
+ expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
249
+ });
250
+ it('값이 바뀌지 않았으면 blur 만으로는 통지하지 않는다', async () => {
251
+ const onChangeDate = vi.fn();
252
+ const container = mount({
253
+ currentDate: '2024-03-01',
254
+ onChangeDate,
255
+ datePickerOptions: { allowInput: true },
256
+ });
257
+ const { input } = getInstance(container);
258
+ onChangeDate.mockClear();
259
+ act(() => {
260
+ input.dispatchEvent(new Event('blur'));
261
+ });
262
+ await flushBlurSync();
263
+ expect(onChangeDate).not.toHaveBeenCalled();
264
+ });
265
+ });
266
+ describe('#7 타이핑 즉시 통지 — blur 를 기다리지 않는다 (#410)', () => {
267
+ const getInput = (container) => container.querySelector('input.flatpickr-input');
268
+ /** 사용자가 한 글자씩 타이핑하는 것과 동일하게 input 이벤트를 발생시킨다 */
269
+ const typeInto = (input, text) => {
270
+ input.value = '';
271
+ for (const char of text) {
272
+ act(() => {
273
+ input.value = `${input.value}${char}`;
274
+ input.dispatchEvent(new Event('input', { bubbles: true }));
275
+ });
276
+ }
277
+ };
278
+ const flushBlurSync = async () => {
279
+ await act(async () => {
280
+ await new Promise((resolve) => setTimeout(resolve, 0));
281
+ });
282
+ };
283
+ it('완성된 유효 날짜를 타이핑하면 blur 전에 통지한다', () => {
284
+ const onChangeDate = vi.fn();
285
+ const container = mount({
286
+ currentDate: '2024-03-01',
287
+ onChangeDate,
288
+ datePickerOptions: { allowInput: true },
289
+ });
290
+ typeInto(getInput(container), '2024-03-20');
291
+ expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
292
+ });
293
+ it('구분자 없이 8자리 숫자만 타이핑해도 통지한다', () => {
294
+ const onChangeDate = vi.fn();
295
+ const container = mount({
296
+ currentDate: '2024-03-01',
297
+ onChangeDate,
298
+ datePickerOptions: { allowInput: true },
299
+ });
300
+ // 8자리를 다 치는 순간 핸들러가 값을 '2024-03-20' 으로 다시 쓴다.
301
+ // 그 뒤로는 브라우저가 input 이벤트를 더 주지 않으므로 이 시점에 통지해야 한다.
302
+ typeInto(getInput(container), '20240320');
303
+ expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
304
+ });
305
+ it('아직 완성되지 않은 입력은 통지하지 않는다', () => {
306
+ const onChangeDate = vi.fn();
307
+ const container = mount({
308
+ currentDate: '2024-03-01',
309
+ onChangeDate,
310
+ datePickerOptions: { allowInput: true },
311
+ });
312
+ typeInto(getInput(container), '2024-03-');
313
+ expect(onChangeDate).not.toHaveBeenCalled();
314
+ });
315
+ it('타이핑으로 이미 통지했으면 blur 가 중복 통지하지 않는다', async () => {
316
+ const onChangeDate = vi.fn();
317
+ const container = mount({
318
+ currentDate: '2024-03-01',
319
+ onChangeDate,
320
+ datePickerOptions: { allowInput: true },
321
+ });
322
+ const input = getInput(container);
323
+ typeInto(input, '2024-03-20');
324
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
325
+ act(() => {
326
+ input.dispatchEvent(new Event('blur'));
327
+ });
328
+ await flushBlurSync();
329
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
330
+ });
331
+ it('min/max 범위 밖 날짜는 타이핑 중에 통지하지 않는다 (blur 시점에 한 번만 보고)', () => {
332
+ const onChangeDate = vi.fn();
333
+ const onValidationError = vi.fn();
334
+ const container = mount({
335
+ currentDate: '2024-03-15',
336
+ onChangeDate,
337
+ onValidationError,
338
+ datePickerOptions: { minDate: '2024-03-10', maxDate: '2024-03-25', allowInput: true },
339
+ });
340
+ typeInto(getInput(container), '2024-04-30');
341
+ expect(onChangeDate).not.toHaveBeenCalled();
342
+ expect(onValidationError).not.toHaveBeenCalled();
343
+ });
344
+ it('같은 값은 경로가 달라도 두 번 통지하지 않는다', () => {
345
+ const onChangeDate = vi.fn();
346
+ const container = mount({
347
+ currentDate: '2024-03-01',
348
+ onChangeDate,
349
+ datePickerOptions: { allowInput: true },
350
+ });
351
+ const input = getInput(container);
352
+ // 타이핑으로 통지한 뒤, 캘린더가 같은 날짜로 onChange 를 다시 발화시키는 상황
353
+ typeInto(input, '2024-03-20');
354
+ act(() => {
355
+ input._flatpickr.setDate('2024-03-20', true);
356
+ });
357
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
358
+ });
359
+ it('enableTime 모드에서 날짜만 입력한 상태는 타이핑 중에 통지하지 않는다', () => {
360
+ const onChangeDate = vi.fn();
361
+ const container = mount({
362
+ currentDate: '2024-03-01 00:00',
363
+ onChangeDate,
364
+ datePickerOptions: { enableTime: true, dateFormat: 'Y-m-d H:i', allowInput: true },
365
+ });
366
+ // '2024-03-20' 은 최종 표기('2024-03-20 00:00')와 다르다.
367
+ // 여기서 통지하면 setDate 가 input 을 다시 써서 타이핑 중 커서가 튄다.
368
+ typeInto(getInput(container), '2024-03-20');
369
+ expect(onChangeDate).not.toHaveBeenCalled();
370
+ });
371
+ });
372
+ describe('#8 완성 판정·파싱을 dateFormat 기준으로 한다', () => {
373
+ const getInput = (container) => container.querySelector('input.flatpickr-input');
374
+ const typeInto = (input, text) => {
375
+ input.value = '';
376
+ for (const char of text) {
377
+ act(() => {
378
+ input.value = `${input.value}${char}`;
379
+ input.dispatchEvent(new Event('input', { bubbles: true }));
380
+ });
381
+ }
382
+ };
383
+ it('enableTime 에서 한 글자씩 쳐도 입력이 유지되고 통지된다', () => {
384
+ const onChangeDate = vi.fn();
385
+ const container = mount({
386
+ currentDate: '2026-03-20 23:59',
387
+ onChangeDate,
388
+ datePickerOptions: { allowInput: true, enableTime: true, dateFormat: 'Y-m-d H:i' },
389
+ });
390
+ const input = getInput(container);
391
+ // 완성 길이를 10 으로 고정하면 '2026-03-01 0' 을 완성으로 오판해
392
+ // moment 검증에 걸리고, 입력이 이전 값('2026-03-20 23:59')으로 되돌아갔다
393
+ typeInto(input, '2026-03-01 00:00');
394
+ expect(input.value).toBe('2026-03-01 00:00');
395
+ expect(onChangeDate).toHaveBeenCalledWith('2026-03-01 00:00');
396
+ });
397
+ it('enableTime 에서 시간을 아직 안 친 중간 상태는 통지하지 않는다', () => {
398
+ const onChangeDate = vi.fn();
399
+ const container = mount({
400
+ currentDate: '2026-03-20 23:59',
401
+ onChangeDate,
402
+ datePickerOptions: { allowInput: true, enableTime: true, dateFormat: 'Y-m-d H:i' },
403
+ });
404
+ typeInto(getInput(container), '2026-03-01 00');
405
+ expect(onChangeDate).not.toHaveBeenCalled();
406
+ });
407
+ it('시간 전용(noCalendar) 모드에서 타이핑한 값이 되돌려지지 않는다', () => {
408
+ const onChangeDate = vi.fn();
409
+ const container = mount({
410
+ currentDate: '23:59',
411
+ onChangeDate,
412
+ datePickerOptions: { allowInput: true, enableTime: true, noCalendar: true, dateFormat: 'H:i' },
413
+ });
414
+ const input = getInput(container);
415
+ // 포맷 없이 moment('14:30') 으로 파싱하면 invalid 라 이전 값으로 복원됐다
416
+ typeInto(input, '14:30');
417
+ expect(input.value).toBe('14:30');
418
+ expect(onChangeDate).toHaveBeenCalledWith('14:30');
419
+ });
420
+ it('완성 길이에 도달했지만 실재하지 않는 날짜는 이전 값으로 복원한다', () => {
421
+ const onChangeDate = vi.fn();
422
+ const container = mount({
423
+ currentDate: '2026-03-20',
424
+ onChangeDate,
425
+ datePickerOptions: { allowInput: true },
426
+ });
427
+ const input = getInput(container);
428
+ typeInto(input, '2026-13-45');
429
+ expect(input.value).not.toBe('2026-13-45');
430
+ expect(onChangeDate).not.toHaveBeenCalledWith('2026-13-45');
431
+ });
432
+ });
@@ -13,6 +13,15 @@ type RangeDatePickerProps = {
13
13
  period: number;
14
14
  };
15
15
  };
16
+ /**
17
+ * 검증 결과 통지. 시작일·종료일 effect 가 각자 자기 쪽 변경만 검증하므로,
18
+ * 사용자가 한쪽 날짜를 바꾸면 그 한쪽(`type`)만 통지된다.
19
+ *
20
+ * 단 **마운트 시점에 이미 역전된 범위**가 들어오면 두 effect 가 함께 돌아
21
+ * `type:'start'` 과 `type:'end'` 가 각각 통지된다. 이때 `newDate` 는 서로 반대편 날짜이므로
22
+ * 양쪽을 모두 반영하면 시작일·종료일이 뒤바뀐다. 한쪽만 처리해야 한다.
23
+ * (validationOption.setting 을 넘기는 사용처에서는 종전부터 동일하게 동작했다.)
24
+ */
16
25
  onDateValidation?: (params: {
17
26
  type: 'start' | 'end';
18
27
  errorType: 'reset' | 'period' | 'overlap' | null;
@@ -90,17 +90,20 @@ const RangeDatePicker = forwardRef(({ startDateOptions, endDateOptions, validati
90
90
  if (!areBothDatesValid(startDateOptions.currentDate, endDateOptions.currentDate)) {
91
91
  return;
92
92
  }
93
- // 종료일이 '오늘'이면 종료측 검증을 건너뛴다: shopby 종료일=오늘이 정상 기본값이라
94
- // (고도몰=어제) 상태를 에러로 잡지 않기 위함. (도입 d1bd8751)
95
- const isNotTodayEndDate = !moment(endDateOptions.currentDate).isSame(moment(), 'day');
96
- if (!validationOption?.setting || !isNotTodayEndDate) {
97
- return;
98
- }
93
+ // overlap(종료일 < 시작일)은 시작일 effect 동일하게 setting 유무보다 먼저 검증한다.
94
+ // 과거에는 아래 setting 가드 뒤에 있어서, setting 넘기지 않는 사용처가 종료일을 시작일보다
95
+ // 앞으로 두어도 onDateValidation 이 호출되지 않았다. (#410)
99
96
  const isOverDate = moment(endDateOptions.currentDate).isBefore(startDateOptions.currentDate);
100
97
  if (isOverDate) {
101
98
  changeSettingDateAndAlert('end');
102
99
  return;
103
100
  }
101
+ // 종료일이 '오늘'이면 기간(period) 검증은 건너뛴다: shopby 는 종료일=오늘이 정상 기본값이라
102
+ // (고도몰=어제) 이 상태를 에러로 잡지 않기 위함. (도입 d1bd8751)
103
+ const isNotTodayEndDate = !moment(endDateOptions.currentDate).isSame(moment(), 'day');
104
+ if (!validationOption?.setting || !isNotTodayEndDate) {
105
+ return;
106
+ }
104
107
  const { unit, period } = validationOption.setting;
105
108
  const isValidPeriod = moment(endDateOptions.currentDate).isSameOrBefore(moment(startDateOptions.currentDate).add(period, unit));
106
109
  if (isValidPeriod) {
@@ -58,3 +58,43 @@ describe('#1 빈/무효 날짜 검증 가드', () => {
58
58
  expect(onDateValidation).toHaveBeenCalled();
59
59
  });
60
60
  });
61
+ describe('#2 validationOption.setting 없이도 overlap 을 검증한다 (#410)', () => {
62
+ it('종료일이 시작일보다 이전이면 type:end / errorType:overlap 으로 통지한다', () => {
63
+ const onDateValidation = vi.fn();
64
+ mount({
65
+ startDateOptions: { currentDate: '2024-03-10', onChangeDate: () => undefined },
66
+ endDateOptions: { currentDate: '2024-03-05', onChangeDate: () => undefined },
67
+ onDateValidation,
68
+ });
69
+ expect(onDateValidation).toHaveBeenCalledWith({
70
+ type: 'end',
71
+ errorType: 'overlap',
72
+ newDate: '2024-03-10',
73
+ currentDate: '2024-03-05',
74
+ });
75
+ });
76
+ it('시작일이 종료일보다 이후이면 type:start / errorType:overlap 으로 통지한다', () => {
77
+ const onDateValidation = vi.fn();
78
+ mount({
79
+ startDateOptions: { currentDate: '2024-03-10', onChangeDate: () => undefined },
80
+ endDateOptions: { currentDate: '2024-03-05', onChangeDate: () => undefined },
81
+ onDateValidation,
82
+ });
83
+ expect(onDateValidation).toHaveBeenCalledWith({
84
+ type: 'start',
85
+ errorType: 'overlap',
86
+ newDate: '2024-03-05',
87
+ currentDate: '2024-03-10',
88
+ });
89
+ });
90
+ it('정상 범위(시작일 <= 종료일)면 setting 없이 overlap 을 통지하지 않는다', () => {
91
+ const onDateValidation = vi.fn();
92
+ mount({
93
+ startDateOptions: { currentDate: '2024-03-01', onChangeDate: () => undefined },
94
+ endDateOptions: { currentDate: '2024-03-05', onChangeDate: () => undefined },
95
+ onDateValidation,
96
+ });
97
+ const overlapCalls = onDateValidation.mock.calls.filter(([arg]) => arg?.errorType === 'overlap');
98
+ expect(overlapCalls).toHaveLength(0);
99
+ });
100
+ });
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { createElement } from 'react';
2
+ import { createElement, useState } from 'react';
3
3
  import { createRoot } from 'react-dom/client';
4
4
  import { act } from 'react-dom/test-utils';
5
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -40,3 +40,192 @@ describe('#6 periodItems 에 없는 periodKey 방어', () => {
40
40
  expect(container.textContent).toContain('오늘');
41
41
  });
42
42
  });
43
+ describe('#9 allowInput 타이핑 → onDateValidation (#410)', () => {
44
+ const START_DATE = '2026-03-10';
45
+ const END_DATE = '2026-03-20';
46
+ /** 시작일보다 이전 — 종료일에 넣으면 overlap */
47
+ const TYPED_END_DATE = '2026-03-01';
48
+ /** 실제 사용처처럼 부모가 state 를 들고 controlled 로 쓰는 래퍼.
49
+ * bump() 로 날짜와 무관한 리렌더를 임의 시점에 끼워 넣을 수 있다. */
50
+ function mountHarness(onDateValidation) {
51
+ let bump;
52
+ const Harness = () => {
53
+ const [start, setStart] = useState(START_DATE);
54
+ const [end, setEnd] = useState(END_DATE);
55
+ const [, setTick] = useState(0);
56
+ const [buttonId, setButtonId] = useState('NONE');
57
+ bump = () => setTick((tick) => tick + 1);
58
+ return createElement(RangeDatePickerWithButtons, {
59
+ currentButtonId: buttonId,
60
+ setCurrentButtonId: setButtonId,
61
+ periodKeys: ['ENTIRE', 'TODAY'],
62
+ startDateOptions: { currentDate: start, onChangeDate: setStart, datePickerOptions: { allowInput: true } },
63
+ endDateOptions: { currentDate: end, onChangeDate: setEnd, datePickerOptions: { allowInput: true } },
64
+ onDateValidation,
65
+ });
66
+ };
67
+ const container = document.createElement('div');
68
+ document.body.appendChild(container);
69
+ const root = createRoot(container);
70
+ act(() => {
71
+ root.render(createElement(Harness));
72
+ });
73
+ mounted.push({ root, container });
74
+ const inputs = Array.from(container.querySelectorAll('input.flatpickr-input'));
75
+ return { endInput: inputs[1], bump: () => bump?.() };
76
+ }
77
+ const typeChar = (input, char) => {
78
+ act(() => {
79
+ input.value = `${input.value}${char}`;
80
+ input.dispatchEvent(new Event('input', { bubbles: true }));
81
+ });
82
+ };
83
+ it('종료일에 시작일 이전 날짜를 타이핑하면 overlap 을 통지한다', () => {
84
+ const onDateValidation = vi.fn();
85
+ const { endInput } = mountHarness(onDateValidation);
86
+ onDateValidation.mockClear();
87
+ endInput.value = '';
88
+ for (const char of TYPED_END_DATE) {
89
+ typeChar(endInput, char);
90
+ }
91
+ expect(onDateValidation).toHaveBeenCalledWith({
92
+ type: 'end',
93
+ errorType: 'overlap',
94
+ newDate: START_DATE,
95
+ currentDate: TYPED_END_DATE,
96
+ });
97
+ });
98
+ it('blur 없이 통지하므로, 통지 이후 부모가 리렌더돼도 결과가 뒤집히지 않는다', () => {
99
+ const onDateValidation = vi.fn();
100
+ const { endInput, bump } = mountHarness(onDateValidation);
101
+ onDateValidation.mockClear();
102
+ endInput.value = '';
103
+ for (const char of TYPED_END_DATE) {
104
+ typeChar(endInput, char);
105
+ }
106
+ onDateValidation.mockClear();
107
+ // 날짜와 무관한 리렌더가 뒤따라도 통지된 값이 유지된다
108
+ act(() => {
109
+ bump();
110
+ });
111
+ expect(endInput.value).toBe(TYPED_END_DATE);
112
+ });
113
+ /**
114
+ * 알려진 한계: 날짜를 다 치기 전에 리렌더가 끼면 react-flatpickr 가 input 을 부모 값으로 덮는다.
115
+ * (static:true 라 wrapper 를 헐고 다시 만들면서 input DOM 이 이동해 포커스까지 빠진다.)
116
+ * 통지 자체는 blur 를 안 기다리므로 위 케이스와 무관하지만, 이 구간은 여전히 취약하다.
117
+ */
118
+ it('입력이 완성되기 전 리렌더가 끼면 입력값이 부모 값으로 되돌아간다 (알려진 한계)', () => {
119
+ const onDateValidation = vi.fn();
120
+ const { endInput, bump } = mountHarness(onDateValidation);
121
+ endInput.value = '';
122
+ for (const char of TYPED_END_DATE.slice(0, -1)) {
123
+ typeChar(endInput, char);
124
+ }
125
+ act(() => {
126
+ bump();
127
+ });
128
+ expect(endInput.value).toBe(END_DATE);
129
+ });
130
+ });
131
+ describe('#10 같은 날짜를 다시 입력해도 통지된다 (#410 재보고)', () => {
132
+ const START_DATE = '2026-08-10';
133
+ const END_DATE = '2026-08-20';
134
+ /** 시작일 이전 — overlap */
135
+ const TYPED_END_DATE = '2026-08-06';
136
+ const typeChars = (input, text) => {
137
+ input.value = '';
138
+ for (const char of text) {
139
+ act(() => {
140
+ input.value = `${input.value}${char}`;
141
+ input.dispatchEvent(new Event('input', { bubbles: true }));
142
+ });
143
+ }
144
+ };
145
+ it('overlap 통지를 받고 종료일을 되돌린 뒤 같은 날짜를 다시 쳐도 매번 통지한다', () => {
146
+ const onDateValidation = vi.fn();
147
+ // 실제 사용처 패턴: overlap 을 받으면 알럿 대신 종료일을 원래대로 되돌린다
148
+ const Harness = () => {
149
+ const [start, setStart] = useState(START_DATE);
150
+ const [end, setEnd] = useState(END_DATE);
151
+ const [buttonId, setButtonId] = useState('NONE');
152
+ return createElement(RangeDatePickerWithButtons, {
153
+ currentButtonId: buttonId,
154
+ setCurrentButtonId: setButtonId,
155
+ periodKeys: ['ENTIRE', 'TODAY'],
156
+ startDateOptions: { currentDate: start, onChangeDate: setStart, datePickerOptions: { allowInput: true } },
157
+ endDateOptions: { currentDate: end, onChangeDate: setEnd, datePickerOptions: { allowInput: true } },
158
+ onDateValidation: (params) => {
159
+ onDateValidation(params);
160
+ if (params.type === 'end' && params.errorType === 'overlap')
161
+ setEnd(END_DATE);
162
+ },
163
+ });
164
+ };
165
+ const container = document.createElement('div');
166
+ document.body.appendChild(container);
167
+ const root = createRoot(container);
168
+ act(() => {
169
+ root.render(createElement(Harness));
170
+ });
171
+ mounted.push({ root, container });
172
+ const endInput = Array.from(container.querySelectorAll('input.flatpickr-input'))[1];
173
+ onDateValidation.mockClear();
174
+ // 종전에는 lastNotifiedDate 가 부모 갱신 후에도 남아 있어 2회차부터 통지가 끊겼다
175
+ const ATTEMPT_COUNT = 3;
176
+ const firedPerAttempt = [];
177
+ for (let attempt = 0; attempt < ATTEMPT_COUNT; attempt++) {
178
+ const before = onDateValidation.mock.calls.length;
179
+ typeChars(endInput, TYPED_END_DATE);
180
+ firedPerAttempt.push(onDateValidation.mock.calls.length - before);
181
+ }
182
+ expect(firedPerAttempt).toEqual([1, 1, 1]);
183
+ });
184
+ });
185
+ describe('#11 통지 후 사용처가 날짜를 되돌려도 입력 칸의 포커스가 유지된다 (#416)', () => {
186
+ const START_DATE = '2026-08-10';
187
+ const END_DATE = '2026-08-20';
188
+ /** 시작일 이전 — overlap */
189
+ const TYPED_END_DATE = '2026-08-06';
190
+ it('overlap 통지 → 되돌리기 후에도 input 이 포커스를 잃지 않는다', () => {
191
+ const Harness = () => {
192
+ const [start, setStart] = useState(START_DATE);
193
+ const [end, setEnd] = useState(END_DATE);
194
+ const [buttonId, setButtonId] = useState('NONE');
195
+ return createElement(RangeDatePickerWithButtons, {
196
+ currentButtonId: buttonId,
197
+ setCurrentButtonId: setButtonId,
198
+ periodKeys: ['ENTIRE', 'TODAY'],
199
+ startDateOptions: { currentDate: start, onChangeDate: setStart, datePickerOptions: { allowInput: true } },
200
+ endDateOptions: { currentDate: end, onChangeDate: setEnd, datePickerOptions: { allowInput: true } },
201
+ onDateValidation: (params) => {
202
+ // 알럿 후 날짜를 되돌리는 사용처 패턴
203
+ if (params.type === 'end' && params.errorType === 'overlap')
204
+ setEnd(END_DATE);
205
+ },
206
+ });
207
+ };
208
+ const container = document.createElement('div');
209
+ document.body.appendChild(container);
210
+ const root = createRoot(container);
211
+ act(() => {
212
+ root.render(createElement(Harness));
213
+ });
214
+ mounted.push({ root, container });
215
+ const endInput = Array.from(container.querySelectorAll('input.flatpickr-input'))[1];
216
+ // 사용자가 칸을 클릭해 포커스를 쥔 상태로 입력한다
217
+ endInput.focus();
218
+ expect(document.activeElement).toBe(endInput);
219
+ endInput.value = '';
220
+ for (const char of TYPED_END_DATE) {
221
+ act(() => {
222
+ endInput.value = `${endInput.value}${char}`;
223
+ endInput.dispatchEvent(new Event('input', { bubbles: true }));
224
+ });
225
+ }
226
+ // 되돌리기 리렌더에서 flatpickr 가 wrapper 를 재생성하며 input 노드를 옮겨 포커스가 빠졌다.
227
+ // 사용자는 알럿을 닫고 이어서 타이핑하는데 입력이 들어가지 않아 콜백이 끊긴 것처럼 보였다.
228
+ expect(endInput.value).toBe(END_DATE);
229
+ expect(document.activeElement).toBe(endInput);
230
+ });
231
+ });
@@ -1,4 +1,9 @@
1
+ import path from 'node:path';
1
2
  import { defineConfig } from 'vitest/config';
3
+ // lcov 의 SF 경로를 레포 루트 기준으로 기록한다. sonar-scanner 는 레포 루트를 단일
4
+ // projectBaseDir 로 잡으므로, 패키지 루트 기준 상대경로(SF:src/foo.ts)로 두면 경로가
5
+ // 해석되지 않아 커버리지가 통째로 무시된다.
6
+ const REPO_ROOT = path.resolve(__dirname, '../..');
2
7
  export default defineConfig({
3
8
  test: {
4
9
  include: [
@@ -7,5 +12,24 @@ export default defineConfig({
7
12
  'assets/scripts/**/__tests__/**/*.test.ts',
8
13
  ],
9
14
  environment: 'node',
15
+ coverage: {
16
+ provider: 'v8',
17
+ // 테스트가 존재하는 3개 루트를 분모로 잡는다 (test.include 와 동일 범위).
18
+ include: ['src/**/*.{ts,tsx}', 'scripts/**/*.ts', 'assets/scripts/**/*.ts'],
19
+ exclude: [
20
+ '**/__tests__/**',
21
+ '**/*.d.ts',
22
+ // ui-admin 의 index.ts 81개는 전부 `export * from ...` barrel 이라 측정 의미가 없다.
23
+ // (step-guide 의 src/index.ts 는 824줄 본체 구현이므로 그쪽에서는 제외하지 않는다.)
24
+ '**/index.ts',
25
+ // MCP 가 읽는 컴포넌트 메타데이터 선언 45개 — 실행 분기 없는 순수 데이터.
26
+ '**/*.meta.ts',
27
+ 'src/types/**',
28
+ 'src/constant/**',
29
+ ],
30
+ // 'text' 미포함 — 미커버 파일이 수백 개라 파일별 상세 출력이 CI 로그를 덮는다.
31
+ // 'lcov' 대신 'lcovonly' — 'lcov' 는 HTML 리포트까지 생성한다.
32
+ reporter: ['text-summary', ['lcovonly', { projectRoot: REPO_ROOT }]],
33
+ },
10
34
  },
11
35
  });
@@ -13,6 +13,15 @@ type RangeDatePickerProps = {
13
13
  period: number;
14
14
  };
15
15
  };
16
+ /**
17
+ * 검증 결과 통지. 시작일·종료일 effect 가 각자 자기 쪽 변경만 검증하므로,
18
+ * 사용자가 한쪽 날짜를 바꾸면 그 한쪽(`type`)만 통지된다.
19
+ *
20
+ * 단 **마운트 시점에 이미 역전된 범위**가 들어오면 두 effect 가 함께 돌아
21
+ * `type:'start'` 과 `type:'end'` 가 각각 통지된다. 이때 `newDate` 는 서로 반대편 날짜이므로
22
+ * 양쪽을 모두 반영하면 시작일·종료일이 뒤바뀐다. 한쪽만 처리해야 한다.
23
+ * (validationOption.setting 을 넘기는 사용처에서는 종전부터 동일하게 동작했다.)
24
+ */
16
25
  onDateValidation?: (params: {
17
26
  type: 'start' | 'end';
18
27
  errorType: 'reset' | 'period' | 'overlap' | null;