@ncds/ui-admin 1.8.16 → 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.
- package/dist/cjs/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
- package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
- package/dist/cjs/src/components/forms-and-input/file-input/FileInput.js +25 -7
- package/dist/cjs/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +168 -0
- package/dist/cjs/src/components/forms-and-input/image-file-input/ImageFileInput.js +76 -19
- package/dist/cjs/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +533 -0
- package/dist/cjs/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
- package/dist/cjs/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
- package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +249 -0
- package/dist/cjs/src/hooks/__tests__/useObjectUrl.test.js +120 -0
- package/dist/cjs/src/hooks/index.js +11 -0
- package/dist/cjs/src/hooks/useObjectUrl.js +39 -0
- package/dist/cjs/vitest.config.js +23 -1
- package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
- package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
- package/dist/esm/src/components/forms-and-input/file-input/FileInput.js +25 -7
- package/dist/esm/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +165 -0
- package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +71 -19
- package/dist/esm/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +530 -0
- package/dist/esm/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
- package/dist/esm/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
- package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +250 -1
- package/dist/esm/src/hooks/__tests__/useObjectUrl.test.js +117 -0
- package/dist/esm/src/hooks/index.js +2 -1
- package/dist/esm/src/hooks/useObjectUrl.js +32 -0
- package/dist/esm/vitest.config.js +22 -1
- package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +184 -36
- package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +323 -0
- package/dist/temp/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
- package/dist/temp/src/components/forms-and-input/file-input/FileInput.js +16 -3
- package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +139 -0
- package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
- package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +63 -18
- package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +435 -0
- package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
- package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.js +7 -2
- package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +40 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +190 -1
- package/dist/temp/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
- package/dist/temp/src/hooks/__tests__/useObjectUrl.test.js +96 -0
- package/dist/temp/src/hooks/index.d.ts +1 -0
- package/dist/temp/src/hooks/index.js +1 -0
- package/dist/temp/src/hooks/useObjectUrl.d.ts +16 -0
- package/dist/temp/src/hooks/useObjectUrl.js +32 -0
- package/dist/temp/vitest.config.js +24 -0
- package/dist/types/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
- package/dist/types/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
- package/dist/types/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
- package/dist/types/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
- package/dist/types/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
- package/dist/types/src/hooks/index.d.ts +1 -0
- package/dist/types/src/hooks/useObjectUrl.d.ts +16 -0
- package/dist/ui-admin/assets/styles/style.css +20 -4
- package/package.json +5 -3
|
@@ -4,6 +4,12 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.ImageFileInput = void 0;
|
|
7
|
+
Object.defineProperty(exports, "ImageFileInputErrorType", {
|
|
8
|
+
enumerable: true,
|
|
9
|
+
get: function () {
|
|
10
|
+
return _FileInput.FileInputErrorType;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
7
13
|
var _jsxRuntime = require("react/jsx-runtime");
|
|
8
14
|
var _uiAdminIcon = require("@ncds/ui-admin-icon");
|
|
9
15
|
var _classnames = _interopRequireDefault(require("classnames"));
|
|
@@ -37,6 +43,8 @@ const ImageFileInput = exports.ImageFileInput = /*#__PURE__*/(0, _react.forwardR
|
|
|
37
43
|
maxFileCount,
|
|
38
44
|
value,
|
|
39
45
|
onChange,
|
|
46
|
+
uploadedFiles = [],
|
|
47
|
+
onUploadedFilesChange,
|
|
40
48
|
onFileSelect,
|
|
41
49
|
onFail,
|
|
42
50
|
buttonLabel = '파일 찾기',
|
|
@@ -61,6 +69,9 @@ const ImageFileInput = exports.ImageFileInput = /*#__PURE__*/(0, _react.forwardR
|
|
|
61
69
|
// Determine if component is controlled or uncontrolled
|
|
62
70
|
const isControlled = value !== undefined;
|
|
63
71
|
const files = isControlled ? value : internalFiles;
|
|
72
|
+
// An entry without a URL has nothing to render, so it must not occupy a slot either.
|
|
73
|
+
// TypeScript requires fileImageUrl, but CDN/vanilla consumers are untyped.
|
|
74
|
+
const visibleUploadedFiles = uploadedFiles.filter(uploadedFile => Boolean(uploadedFile.fileImageUrl));
|
|
64
75
|
// Sync internal state with controlled value
|
|
65
76
|
(0, _react.useEffect)(() => {
|
|
66
77
|
if (isControlled && value) {
|
|
@@ -82,32 +93,57 @@ const ImageFileInput = exports.ImageFileInput = /*#__PURE__*/(0, _react.forwardR
|
|
|
82
93
|
validFiles,
|
|
83
94
|
invalidFiles
|
|
84
95
|
} = validateFiles(Array.from(selectedFiles));
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
// Nothing passed validation — keep what the caller already had.
|
|
97
|
+
// Without this, a single slot would be overwritten with an empty list.
|
|
98
|
+
if (validFiles.length > 0) {
|
|
99
|
+
// Replace existing file if maxFileCount is 1
|
|
100
|
+
const nextFiles = maxFileCount === 1 ? validFiles : [...files, ...validFiles];
|
|
101
|
+
updateFiles(nextFiles);
|
|
102
|
+
// A single slot holds one image, so a new selection also clears the stored one.
|
|
103
|
+
// Entries we never rendered occupy no slot, so they are left in the caller's list.
|
|
104
|
+
if (maxFileCount === 1 && visibleUploadedFiles.length > 0) {
|
|
105
|
+
onUploadedFilesChange?.(uploadedFiles.filter(uploadedFile => !uploadedFile.fileImageUrl));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
88
108
|
if (onFail && invalidFiles.length > 0) {
|
|
89
109
|
onFail(invalidFiles);
|
|
90
110
|
}
|
|
91
111
|
event.target.value = '';
|
|
92
112
|
};
|
|
113
|
+
/**
|
|
114
|
+
* Returns why a file must be rejected, or null when it is acceptable.
|
|
115
|
+
* `acceptedCount` is how many files of the same selection already passed.
|
|
116
|
+
*/
|
|
117
|
+
const findRejectionReason = (file, acceptedCount) => {
|
|
118
|
+
if (files.some(f => f.name === file.name && f.size === file.size)) {
|
|
119
|
+
return _FileInput.FileInputErrorType.ALREADY_UPLOADED;
|
|
120
|
+
}
|
|
121
|
+
// Stored images carry no size, so they can only be matched by name.
|
|
122
|
+
// A single slot is meant to be replaced, so re-picking the same name is allowed there.
|
|
123
|
+
if (maxFileCount !== 1 && visibleUploadedFiles.some(uploadedFile => uploadedFile.fileName === file.name)) {
|
|
124
|
+
return _FileInput.FileInputErrorType.ALREADY_UPLOADED;
|
|
125
|
+
}
|
|
126
|
+
if (!!maxFileSize && file.size > maxFileSize) {
|
|
127
|
+
return _FileInput.FileInputErrorType.EXCEED_MAX_FILE_SIZE;
|
|
128
|
+
}
|
|
129
|
+
// Skip max count check if maxFileCount is 1 (allow replacement).
|
|
130
|
+
// Stored images occupy slots too, so they count toward the limit.
|
|
131
|
+
const usedSlots = visibleUploadedFiles.length + files.length + acceptedCount;
|
|
132
|
+
if (!!maxFileCount && maxFileCount !== 1 && usedSlots >= maxFileCount) {
|
|
133
|
+
return _FileInput.FileInputErrorType.EXCEED_MAX_FILE_COUNT;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
93
137
|
const validateFiles = fileList => {
|
|
94
138
|
const validFiles = [];
|
|
95
139
|
const invalidFiles = [];
|
|
96
140
|
for (const file of fileList) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
invalidFiles.push(toInvalidFile(file, _FileInput.FileInputErrorType.EXCEED_MAX_FILE_SIZE));
|
|
103
|
-
continue;
|
|
141
|
+
const rejectionReason = findRejectionReason(file, validFiles.length);
|
|
142
|
+
if (rejectionReason) {
|
|
143
|
+
invalidFiles.push(toInvalidFile(file, rejectionReason));
|
|
144
|
+
} else {
|
|
145
|
+
validFiles.push(file);
|
|
104
146
|
}
|
|
105
|
-
// Skip max count check if maxFileCount is 1 (allow replacement)
|
|
106
|
-
if (!!maxFileCount && maxFileCount !== 1 && files.length + validFiles.length >= maxFileCount) {
|
|
107
|
-
invalidFiles.push(toInvalidFile(file, _FileInput.FileInputErrorType.EXCEED_MAX_FILE_COUNT));
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
validFiles.push(file);
|
|
111
147
|
}
|
|
112
148
|
return {
|
|
113
149
|
validFiles,
|
|
@@ -124,13 +160,28 @@ const ImageFileInput = exports.ImageFileInput = /*#__PURE__*/(0, _react.forwardR
|
|
|
124
160
|
newFiles.splice(index, 1);
|
|
125
161
|
updateFiles(newFiles);
|
|
126
162
|
};
|
|
163
|
+
// Removed by identity, not index, so keys stay stable and the caller's
|
|
164
|
+
// untouched entries (including ones we skip rendering) are preserved
|
|
165
|
+
const handleRemoveUploadedFile = target => {
|
|
166
|
+
onUploadedFilesChange?.(uploadedFiles.filter(uploadedFile => uploadedFile !== target));
|
|
167
|
+
};
|
|
127
168
|
const renderImagePreview = function () {
|
|
128
169
|
let files = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
129
|
-
const
|
|
170
|
+
const totalCount = visibleUploadedFiles.length + files.length;
|
|
171
|
+
const showEmptySlot = maxFileCount ? totalCount < maxFileCount : totalCount === 0;
|
|
130
172
|
return (0, _jsxRuntime.jsxs)("div", {
|
|
131
173
|
className: "ncua-image-file-input__previews",
|
|
132
|
-
children: [
|
|
174
|
+
children: [visibleUploadedFiles.map((uploadedFile, index) => (0, _jsxRuntime.jsx)(_ImagePreview.ImagePreview
|
|
175
|
+
// Removal is by identity, so the index here only breaks ties between
|
|
176
|
+
// entries that share both URL and file name.
|
|
177
|
+
, {
|
|
178
|
+
imageUrl: uploadedFile.fileImageUrl,
|
|
179
|
+
alt: uploadedFile.fileName,
|
|
180
|
+
disabled: disabled,
|
|
181
|
+
onRemove: () => handleRemoveUploadedFile(uploadedFile)
|
|
182
|
+
}, `uploaded-${index}-${uploadedFile.fileImageUrl}-${uploadedFile.fileName}`)), files.map((file, index) => (0, _jsxRuntime.jsx)(_ImagePreview.ImagePreview, {
|
|
133
183
|
file: file,
|
|
184
|
+
disabled: disabled,
|
|
134
185
|
onRemove: () => handleRemoveFile(index)
|
|
135
186
|
}, `${file.name}-${index}`)), showEmptySlot && (0, _jsxRuntime.jsxs)("div", {
|
|
136
187
|
className: "ncua-image-file-input__empty-slot-wrapper",
|
|
@@ -206,4 +257,10 @@ const ImageFileInput = exports.ImageFileInput = /*#__PURE__*/(0, _react.forwardR
|
|
|
206
257
|
}), showHintText && renderHintList()]
|
|
207
258
|
})]
|
|
208
259
|
});
|
|
209
|
-
});
|
|
260
|
+
});
|
|
261
|
+
/**
|
|
262
|
+
* Rejection reasons reported through `onFail`.
|
|
263
|
+
*
|
|
264
|
+
* Re-exported under the ImageFileInput name so callers do not have to reach
|
|
265
|
+
* into FileInput for it. Same enum, same values.
|
|
266
|
+
*/
|
package/dist/cjs/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _react = require("react");
|
|
4
|
+
var _client = require("react-dom/client");
|
|
5
|
+
var _testUtils = require("react-dom/test-utils");
|
|
6
|
+
var _vitest = require("vitest");
|
|
7
|
+
var _ImageFileInput = require("../ImageFileInput");
|
|
8
|
+
// @vitest-environment jsdom
|
|
9
|
+
|
|
10
|
+
const mounted = [];
|
|
11
|
+
function mount(props) {
|
|
12
|
+
const container = document.createElement('div');
|
|
13
|
+
document.body.appendChild(container);
|
|
14
|
+
const root = (0, _client.createRoot)(container);
|
|
15
|
+
(0, _testUtils.act)(() => {
|
|
16
|
+
root.render(/*#__PURE__*/(0, _react.createElement)(_ImageFileInput.ImageFileInput, props));
|
|
17
|
+
});
|
|
18
|
+
mounted.push({
|
|
19
|
+
root,
|
|
20
|
+
container
|
|
21
|
+
});
|
|
22
|
+
return container;
|
|
23
|
+
}
|
|
24
|
+
const previewImages = container => Array.from(container.querySelectorAll('.ncua-image-file-input__preview-image'));
|
|
25
|
+
const emptySlot = container => container.querySelector('.ncua-image-file-input__empty-slot-wrapper');
|
|
26
|
+
const makeFile = function (name) {
|
|
27
|
+
let size = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 10;
|
|
28
|
+
return new File([new Uint8Array(size)], name, {
|
|
29
|
+
type: 'image/jpeg'
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* 숨겨진 file input에 파일을 실어 change 이벤트를 발생시킨다.
|
|
34
|
+
* jsdom에는 파일 선택 UI가 없어 files 프로퍼티를 직접 정의해야 한다.
|
|
35
|
+
*/
|
|
36
|
+
function selectFiles(container, filesToSelect) {
|
|
37
|
+
const input = container.querySelector('input[type="file"]');
|
|
38
|
+
if (!input) throw new Error('file input을 찾지 못했습니다');
|
|
39
|
+
Object.defineProperty(input, 'files', {
|
|
40
|
+
configurable: true,
|
|
41
|
+
value: {
|
|
42
|
+
...filesToSelect,
|
|
43
|
+
length: filesToSelect.length,
|
|
44
|
+
item: index => filesToSelect[index] ?? null,
|
|
45
|
+
[Symbol.iterator]: function* () {
|
|
46
|
+
yield* filesToSelect;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
(0, _testUtils.act)(() => {
|
|
51
|
+
input.dispatchEvent(new Event('change', {
|
|
52
|
+
bubbles: true
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
(0, _vitest.afterEach)(() => {
|
|
57
|
+
for (const {
|
|
58
|
+
root,
|
|
59
|
+
container
|
|
60
|
+
} of mounted.splice(0)) {
|
|
61
|
+
(0, _testUtils.act)(() => {
|
|
62
|
+
root.unmount();
|
|
63
|
+
});
|
|
64
|
+
container.remove();
|
|
65
|
+
}
|
|
66
|
+
_vitest.vi.restoreAllMocks();
|
|
67
|
+
});
|
|
68
|
+
(0, _vitest.describe)('ImageFileInput — 저장된 이미지 URL 미리보기', () => {
|
|
69
|
+
(0, _vitest.it)('uploadedFiles의 URL을 img src로 그대로 렌더한다 (fetch 없이)', () => {
|
|
70
|
+
const fetchSpy = _vitest.vi.spyOn(globalThis, 'fetch');
|
|
71
|
+
const container = mount({
|
|
72
|
+
uploadedFiles: [{
|
|
73
|
+
fileName: 'saved.jpg',
|
|
74
|
+
fileImageUrl: 'https://external.example.com/saved.jpg'
|
|
75
|
+
}]
|
|
76
|
+
});
|
|
77
|
+
const images = previewImages(container);
|
|
78
|
+
(0, _vitest.expect)(images).toHaveLength(1);
|
|
79
|
+
(0, _vitest.expect)(images[0].getAttribute('src')).toBe('https://external.example.com/saved.jpg');
|
|
80
|
+
(0, _vitest.expect)(fetchSpy).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
(0, _vitest.it)('fileName을 img alt로 사용한다', () => {
|
|
83
|
+
const container = mount({
|
|
84
|
+
uploadedFiles: [{
|
|
85
|
+
fileName: '상품이미지.jpg',
|
|
86
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
87
|
+
}]
|
|
88
|
+
});
|
|
89
|
+
(0, _vitest.expect)(previewImages(container)[0].getAttribute('alt')).toBe('상품이미지.jpg');
|
|
90
|
+
});
|
|
91
|
+
(0, _vitest.it)('여러 장을 전달한 순서대로 렌더한다', () => {
|
|
92
|
+
const container = mount({
|
|
93
|
+
maxFileCount: 5,
|
|
94
|
+
uploadedFiles: [{
|
|
95
|
+
fileName: 'a.jpg',
|
|
96
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
97
|
+
}, {
|
|
98
|
+
fileName: 'b.jpg',
|
|
99
|
+
fileImageUrl: 'https://external.example.com/b.jpg'
|
|
100
|
+
}]
|
|
101
|
+
});
|
|
102
|
+
(0, _vitest.expect)(previewImages(container).map(img => img.getAttribute('src'))).toEqual(['https://external.example.com/a.jpg', 'https://external.example.com/b.jpg']);
|
|
103
|
+
});
|
|
104
|
+
(0, _vitest.it)('저장된 이미지를 삭제하면 남은 목록으로 onUploadedFilesChange를 호출한다', () => {
|
|
105
|
+
const onUploadedFilesChange = _vitest.vi.fn();
|
|
106
|
+
const container = mount({
|
|
107
|
+
maxFileCount: 5,
|
|
108
|
+
uploadedFiles: [{
|
|
109
|
+
fileName: 'a.jpg',
|
|
110
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
111
|
+
}, {
|
|
112
|
+
fileName: 'b.jpg',
|
|
113
|
+
fileImageUrl: 'https://external.example.com/b.jpg'
|
|
114
|
+
}],
|
|
115
|
+
onUploadedFilesChange
|
|
116
|
+
});
|
|
117
|
+
const removeButtons = container.querySelectorAll('.ncua-image-file-input__preview-remove-button');
|
|
118
|
+
(0, _vitest.expect)(removeButtons).toHaveLength(2);
|
|
119
|
+
(0, _testUtils.act)(() => {
|
|
120
|
+
removeButtons[0].click();
|
|
121
|
+
});
|
|
122
|
+
(0, _vitest.expect)(onUploadedFilesChange).toHaveBeenCalledTimes(1);
|
|
123
|
+
(0, _vitest.expect)(onUploadedFilesChange).toHaveBeenCalledWith([{
|
|
124
|
+
fileName: 'b.jpg',
|
|
125
|
+
fileImageUrl: 'https://external.example.com/b.jpg'
|
|
126
|
+
}]);
|
|
127
|
+
});
|
|
128
|
+
// uploadedFiles는 value/onChange와 같은 controlled prop이다.
|
|
129
|
+
// 콜백을 넘기지 않으면 미리보기가 그대로 유지되는 것이 의도된 동작이며,
|
|
130
|
+
// 이 테스트는 "무반응이어도 괜찮다"가 아니라 그 계약을 고정하기 위한 것이다.
|
|
131
|
+
(0, _vitest.it)('콜백 없이 uploadedFiles만 넘기면 삭제해도 미리보기가 유지된다 (controlled 계약)', () => {
|
|
132
|
+
const container = mount({
|
|
133
|
+
uploadedFiles: [{
|
|
134
|
+
fileName: 'a.jpg',
|
|
135
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
136
|
+
}]
|
|
137
|
+
});
|
|
138
|
+
const removeButton = container.querySelector('.ncua-image-file-input__preview-remove-button');
|
|
139
|
+
(0, _testUtils.act)(() => {
|
|
140
|
+
removeButton?.click();
|
|
141
|
+
});
|
|
142
|
+
(0, _vitest.expect)(previewImages(container)).toHaveLength(1);
|
|
143
|
+
});
|
|
144
|
+
(0, _vitest.it)('같은 URL에 이름만 다른 항목이 있어도 key가 겹치지 않는다', () => {
|
|
145
|
+
// React 는 key 가 겹쳐도 DOM 에는 그려주고 console.error 로만 경고한다.
|
|
146
|
+
// 따라서 렌더 결과가 아니라 경고 발생 여부로 판정해야 한다.
|
|
147
|
+
const consoleError = _vitest.vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
148
|
+
const sharedUrl = 'https://external.example.com/shared.jpg';
|
|
149
|
+
const container = mount({
|
|
150
|
+
maxFileCount: 5,
|
|
151
|
+
uploadedFiles: [{
|
|
152
|
+
fileName: 'first.jpg',
|
|
153
|
+
fileImageUrl: sharedUrl
|
|
154
|
+
}, {
|
|
155
|
+
fileName: 'second.jpg',
|
|
156
|
+
fileImageUrl: sharedUrl
|
|
157
|
+
}]
|
|
158
|
+
});
|
|
159
|
+
(0, _vitest.expect)(previewImages(container).map(img => img.getAttribute('alt'))).toEqual(['first.jpg', 'second.jpg']);
|
|
160
|
+
(0, _vitest.expect)(consoleError.mock.calls.flat(Number.POSITIVE_INFINITY).join(' ')).not.toContain('same key');
|
|
161
|
+
});
|
|
162
|
+
(0, _vitest.it)('URL과 파일명이 모두 같은 항목이 둘이어도 key가 겹치지 않는다', () => {
|
|
163
|
+
const consoleError = _vitest.vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
164
|
+
const same = {
|
|
165
|
+
fileName: 'same.jpg',
|
|
166
|
+
fileImageUrl: 'https://external.example.com/same.jpg'
|
|
167
|
+
};
|
|
168
|
+
const container = mount({
|
|
169
|
+
maxFileCount: 5,
|
|
170
|
+
uploadedFiles: [same, {
|
|
171
|
+
...same
|
|
172
|
+
}]
|
|
173
|
+
});
|
|
174
|
+
(0, _vitest.expect)(previewImages(container)).toHaveLength(2);
|
|
175
|
+
(0, _vitest.expect)(consoleError.mock.calls.flat(Number.POSITIVE_INFINITY).join(' ')).not.toContain('same key');
|
|
176
|
+
});
|
|
177
|
+
(0, _vitest.it)('같은 파일명이 여러 개여도 클릭한 항목만 정확히 제거한다 (순번 아닌 항목 기준 삭제)', () => {
|
|
178
|
+
const onUploadedFilesChange = _vitest.vi.fn();
|
|
179
|
+
const duplicated = [{
|
|
180
|
+
fileName: '같은이름.jpg',
|
|
181
|
+
fileImageUrl: 'https://external.example.com/first.jpg'
|
|
182
|
+
}, {
|
|
183
|
+
fileName: '같은이름.jpg',
|
|
184
|
+
fileImageUrl: 'https://external.example.com/second.jpg'
|
|
185
|
+
}];
|
|
186
|
+
const container = mount({
|
|
187
|
+
maxFileCount: 5,
|
|
188
|
+
uploadedFiles: duplicated,
|
|
189
|
+
onUploadedFilesChange
|
|
190
|
+
});
|
|
191
|
+
(0, _testUtils.act)(() => {
|
|
192
|
+
container.querySelectorAll('.ncua-image-file-input__preview-remove-button')[1].click();
|
|
193
|
+
});
|
|
194
|
+
(0, _vitest.expect)(onUploadedFilesChange).toHaveBeenCalledWith([duplicated[0]]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
(0, _vitest.describe)('ImageFileInput — URL 없는 항목 방어 (CDN/바닐라 등 타입 검사 밖 사용)', () => {
|
|
198
|
+
(0, _vitest.it)('fileImageUrl이 비어 있으면 렌더하지 않는다', () => {
|
|
199
|
+
const container = mount({
|
|
200
|
+
maxFileCount: 3,
|
|
201
|
+
uploadedFiles: [{
|
|
202
|
+
fileName: 'a.jpg',
|
|
203
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
204
|
+
}, {
|
|
205
|
+
fileName: 'broken.jpg',
|
|
206
|
+
fileImageUrl: ''
|
|
207
|
+
}]
|
|
208
|
+
});
|
|
209
|
+
(0, _vitest.expect)(previewImages(container).map(img => img.getAttribute('src'))).toEqual(['https://external.example.com/a.jpg']);
|
|
210
|
+
});
|
|
211
|
+
(0, _vitest.it)('fileImageUrl이 비어 있는 항목은 슬롯을 차지하지 않는다', () => {
|
|
212
|
+
const container = mount({
|
|
213
|
+
maxFileCount: 2,
|
|
214
|
+
uploadedFiles: [{
|
|
215
|
+
fileName: 'a.jpg',
|
|
216
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
217
|
+
}, {
|
|
218
|
+
fileName: 'broken.jpg',
|
|
219
|
+
fileImageUrl: ''
|
|
220
|
+
}]
|
|
221
|
+
});
|
|
222
|
+
// 유효한 1장 < maxFileCount 2 → 빈 슬롯이 남아야 한다
|
|
223
|
+
(0, _vitest.expect)(emptySlot(container)).not.toBeNull();
|
|
224
|
+
});
|
|
225
|
+
(0, _vitest.it)('삭제 시 렌더되지 않은 항목도 호출자 목록에 그대로 남긴다', () => {
|
|
226
|
+
const onUploadedFilesChange = _vitest.vi.fn();
|
|
227
|
+
const broken = {
|
|
228
|
+
fileName: 'broken.jpg',
|
|
229
|
+
fileImageUrl: ''
|
|
230
|
+
};
|
|
231
|
+
const container = mount({
|
|
232
|
+
maxFileCount: 3,
|
|
233
|
+
uploadedFiles: [{
|
|
234
|
+
fileName: 'a.jpg',
|
|
235
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
236
|
+
}, broken],
|
|
237
|
+
onUploadedFilesChange
|
|
238
|
+
});
|
|
239
|
+
(0, _testUtils.act)(() => {
|
|
240
|
+
container.querySelector('.ncua-image-file-input__preview-remove-button')?.click();
|
|
241
|
+
});
|
|
242
|
+
(0, _vitest.expect)(onUploadedFilesChange).toHaveBeenCalledWith([broken]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
(0, _vitest.describe)('ImageFileInput — object URL 해제', () => {
|
|
246
|
+
/** 리렌더를 반복해도 URL이 늘지 않는지 보기 위한 횟수 */
|
|
247
|
+
const REPEATED_RENDERS = 3;
|
|
248
|
+
const originalCreateObjectURL = URL.createObjectURL;
|
|
249
|
+
const originalRevokeObjectURL = URL.revokeObjectURL;
|
|
250
|
+
let createObjectURL;
|
|
251
|
+
let revokeObjectURL;
|
|
252
|
+
(0, _vitest.beforeEach)(() => {
|
|
253
|
+
let issued = 0;
|
|
254
|
+
createObjectURL = _vitest.vi.fn(() => `blob:mock/${++issued}`);
|
|
255
|
+
revokeObjectURL = _vitest.vi.fn();
|
|
256
|
+
URL.createObjectURL = createObjectURL;
|
|
257
|
+
URL.revokeObjectURL = revokeObjectURL;
|
|
258
|
+
});
|
|
259
|
+
(0, _vitest.afterEach)(() => {
|
|
260
|
+
URL.createObjectURL = originalCreateObjectURL;
|
|
261
|
+
URL.revokeObjectURL = originalRevokeObjectURL;
|
|
262
|
+
});
|
|
263
|
+
(0, _vitest.it)('리렌더를 반복해도 파일당 object URL을 한 번만 만든다', () => {
|
|
264
|
+
const file = makeFile('a.jpg');
|
|
265
|
+
const container = document.createElement('div');
|
|
266
|
+
document.body.appendChild(container);
|
|
267
|
+
const root = (0, _client.createRoot)(container);
|
|
268
|
+
for (let renderCount = 0; renderCount < REPEATED_RENDERS; renderCount++) {
|
|
269
|
+
(0, _testUtils.act)(() => {
|
|
270
|
+
root.render(/*#__PURE__*/(0, _react.createElement)(_ImageFileInput.ImageFileInput, {
|
|
271
|
+
value: [file],
|
|
272
|
+
onChange: () => undefined
|
|
273
|
+
}));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
(0, _vitest.expect)(createObjectURL).toHaveBeenCalledTimes(1);
|
|
277
|
+
(0, _testUtils.act)(() => {
|
|
278
|
+
root.unmount();
|
|
279
|
+
});
|
|
280
|
+
container.remove();
|
|
281
|
+
});
|
|
282
|
+
(0, _vitest.it)('언마운트하면 만들었던 object URL을 해제한다', () => {
|
|
283
|
+
const container = mount({
|
|
284
|
+
value: [makeFile('a.jpg')],
|
|
285
|
+
onChange: () => undefined
|
|
286
|
+
});
|
|
287
|
+
(0, _vitest.expect)(createObjectURL).toHaveBeenCalledTimes(1);
|
|
288
|
+
for (const entry of mounted.splice(0)) {
|
|
289
|
+
(0, _testUtils.act)(() => {
|
|
290
|
+
entry.root.unmount();
|
|
291
|
+
});
|
|
292
|
+
entry.container.remove();
|
|
293
|
+
}
|
|
294
|
+
(0, _vitest.expect)(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
|
|
295
|
+
(0, _vitest.expect)(container.isConnected).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
(0, _vitest.describe)('ImageFileInput — 파일 선택 시 저장된 이미지 반영', () => {
|
|
299
|
+
(0, _vitest.it)('저장된 이미지를 포함해 maxFileCount를 초과하면 EXCEED_MAX_FILE_COUNT로 거른다', () => {
|
|
300
|
+
const onFail = _vitest.vi.fn();
|
|
301
|
+
const onChange = _vitest.vi.fn();
|
|
302
|
+
const container = mount({
|
|
303
|
+
maxFileCount: 2,
|
|
304
|
+
value: [],
|
|
305
|
+
onChange,
|
|
306
|
+
onFail,
|
|
307
|
+
uploadedFiles: [{
|
|
308
|
+
fileName: 'a.jpg',
|
|
309
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
310
|
+
}, {
|
|
311
|
+
fileName: 'b.jpg',
|
|
312
|
+
fileImageUrl: 'https://external.example.com/b.jpg'
|
|
313
|
+
}]
|
|
314
|
+
});
|
|
315
|
+
selectFiles(container, [makeFile('new.jpg')]);
|
|
316
|
+
(0, _vitest.expect)(onFail).toHaveBeenCalledTimes(1);
|
|
317
|
+
(0, _vitest.expect)(onFail.mock.calls[0][0][0].errorType).toBe(_ImageFileInput.ImageFileInputErrorType.EXCEED_MAX_FILE_COUNT);
|
|
318
|
+
// 전량 반려이므로 목록을 건드리지 않는다
|
|
319
|
+
(0, _vitest.expect)(onChange).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
(0, _vitest.it)('저장된 이미지와 파일명이 같으면 ALREADY_UPLOADED로 거른다', () => {
|
|
322
|
+
const onFail = _vitest.vi.fn();
|
|
323
|
+
const container = mount({
|
|
324
|
+
maxFileCount: 5,
|
|
325
|
+
value: [],
|
|
326
|
+
onChange: () => undefined,
|
|
327
|
+
onFail,
|
|
328
|
+
uploadedFiles: [{
|
|
329
|
+
fileName: 'same.jpg',
|
|
330
|
+
fileImageUrl: 'https://external.example.com/same.jpg'
|
|
331
|
+
}]
|
|
332
|
+
});
|
|
333
|
+
selectFiles(container, [makeFile('same.jpg')]);
|
|
334
|
+
(0, _vitest.expect)(onFail).toHaveBeenCalledTimes(1);
|
|
335
|
+
(0, _vitest.expect)(onFail.mock.calls[0][0][0].errorType).toBe(_ImageFileInput.ImageFileInputErrorType.ALREADY_UPLOADED);
|
|
336
|
+
});
|
|
337
|
+
(0, _vitest.it)('maxFileCount가 1이면 새 파일 선택 시 저장된 이미지도 비운다', () => {
|
|
338
|
+
const onUploadedFilesChange = _vitest.vi.fn();
|
|
339
|
+
const onChange = _vitest.vi.fn();
|
|
340
|
+
const container = mount({
|
|
341
|
+
maxFileCount: 1,
|
|
342
|
+
value: [],
|
|
343
|
+
onChange,
|
|
344
|
+
uploadedFiles: [{
|
|
345
|
+
fileName: 'old.jpg',
|
|
346
|
+
fileImageUrl: 'https://external.example.com/old.jpg'
|
|
347
|
+
}],
|
|
348
|
+
onUploadedFilesChange
|
|
349
|
+
});
|
|
350
|
+
const picked = makeFile('new.jpg');
|
|
351
|
+
selectFiles(container, [picked]);
|
|
352
|
+
(0, _vitest.expect)(onChange).toHaveBeenCalledWith([picked]);
|
|
353
|
+
(0, _vitest.expect)(onUploadedFilesChange).toHaveBeenCalledWith([]);
|
|
354
|
+
});
|
|
355
|
+
(0, _vitest.it)('maxFileCount가 1이면 저장된 이미지와 이름이 같아도 교체를 허용한다', () => {
|
|
356
|
+
const onFail = _vitest.vi.fn();
|
|
357
|
+
const onChange = _vitest.vi.fn();
|
|
358
|
+
const onUploadedFilesChange = _vitest.vi.fn();
|
|
359
|
+
const container = mount({
|
|
360
|
+
maxFileCount: 1,
|
|
361
|
+
value: [],
|
|
362
|
+
onChange,
|
|
363
|
+
onFail,
|
|
364
|
+
uploadedFiles: [{
|
|
365
|
+
fileName: 'photo.jpg',
|
|
366
|
+
fileImageUrl: 'https://external.example.com/photo.jpg'
|
|
367
|
+
}],
|
|
368
|
+
onUploadedFilesChange
|
|
369
|
+
});
|
|
370
|
+
const picked = makeFile('photo.jpg');
|
|
371
|
+
selectFiles(container, [picked]);
|
|
372
|
+
(0, _vitest.expect)(onFail).not.toHaveBeenCalled();
|
|
373
|
+
(0, _vitest.expect)(onChange).toHaveBeenCalledWith([picked]);
|
|
374
|
+
(0, _vitest.expect)(onUploadedFilesChange).toHaveBeenCalledWith([]);
|
|
375
|
+
});
|
|
376
|
+
(0, _vitest.it)('한 장짜리 교체 시 렌더되지 않은 항목은 호출자 목록에 남긴다', () => {
|
|
377
|
+
const onUploadedFilesChange = _vitest.vi.fn();
|
|
378
|
+
const broken = {
|
|
379
|
+
fileName: 'broken.jpg',
|
|
380
|
+
fileImageUrl: ''
|
|
381
|
+
};
|
|
382
|
+
const container = mount({
|
|
383
|
+
maxFileCount: 1,
|
|
384
|
+
value: [],
|
|
385
|
+
onChange: () => undefined,
|
|
386
|
+
uploadedFiles: [{
|
|
387
|
+
fileName: 'old.jpg',
|
|
388
|
+
fileImageUrl: 'https://external.example.com/old.jpg'
|
|
389
|
+
}, broken],
|
|
390
|
+
onUploadedFilesChange
|
|
391
|
+
});
|
|
392
|
+
selectFiles(container, [makeFile('new.jpg')]);
|
|
393
|
+
(0, _vitest.expect)(onUploadedFilesChange).toHaveBeenCalledWith([broken]);
|
|
394
|
+
});
|
|
395
|
+
(0, _vitest.it)('저장된 이미지가 없으면 maxFileCount 1이어도 불필요하게 콜백을 호출하지 않는다', () => {
|
|
396
|
+
const onUploadedFilesChange = _vitest.vi.fn();
|
|
397
|
+
const container = mount({
|
|
398
|
+
maxFileCount: 1,
|
|
399
|
+
value: [],
|
|
400
|
+
onChange: () => undefined,
|
|
401
|
+
uploadedFiles: [],
|
|
402
|
+
onUploadedFilesChange
|
|
403
|
+
});
|
|
404
|
+
selectFiles(container, [makeFile('new.jpg')]);
|
|
405
|
+
(0, _vitest.expect)(onUploadedFilesChange).not.toHaveBeenCalled();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
(0, _vitest.describe)('ImageFileInput — disabled 읽기 전용', () => {
|
|
409
|
+
const removeButtons = container => container.querySelectorAll('.ncua-image-file-input__preview-remove-button');
|
|
410
|
+
(0, _vitest.it)('disabled면 저장된 이미지의 삭제 버튼을 렌더하지 않는다', () => {
|
|
411
|
+
const container = mount({
|
|
412
|
+
disabled: true,
|
|
413
|
+
maxFileCount: 3,
|
|
414
|
+
uploadedFiles: [{
|
|
415
|
+
fileName: 'a.jpg',
|
|
416
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
417
|
+
}]
|
|
418
|
+
});
|
|
419
|
+
(0, _vitest.expect)(previewImages(container)).toHaveLength(1); // 썸네일은 보인다
|
|
420
|
+
(0, _vitest.expect)(removeButtons(container)).toHaveLength(0); // 삭제 아이콘은 미노출
|
|
421
|
+
});
|
|
422
|
+
(0, _vitest.it)('disabled면 새로 고른 파일의 삭제 버튼도 렌더하지 않는다', () => {
|
|
423
|
+
const container = mount({
|
|
424
|
+
disabled: true,
|
|
425
|
+
maxFileCount: 3,
|
|
426
|
+
value: [makeFile('new.jpg')],
|
|
427
|
+
onChange: () => undefined
|
|
428
|
+
});
|
|
429
|
+
(0, _vitest.expect)(removeButtons(container)).toHaveLength(0);
|
|
430
|
+
});
|
|
431
|
+
(0, _vitest.it)('disabled가 아니면 삭제 버튼을 렌더한다', () => {
|
|
432
|
+
const container = mount({
|
|
433
|
+
maxFileCount: 3,
|
|
434
|
+
uploadedFiles: [{
|
|
435
|
+
fileName: 'a.jpg',
|
|
436
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
437
|
+
}]
|
|
438
|
+
});
|
|
439
|
+
(0, _vitest.expect)(removeButtons(container)).toHaveLength(1);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
(0, _vitest.describe)('ImageFileInput — 전량 반려 시 기존 선택 보존', () => {
|
|
443
|
+
(0, _vitest.it)('maxFileCount가 1일 때 모두 반려되면 이미 고른 파일을 유지한다', () => {
|
|
444
|
+
const already = makeFile('picked.jpg');
|
|
445
|
+
const onChange = _vitest.vi.fn();
|
|
446
|
+
const onFail = _vitest.vi.fn();
|
|
447
|
+
const container = mount({
|
|
448
|
+
maxFileCount: 1,
|
|
449
|
+
value: [already],
|
|
450
|
+
onChange,
|
|
451
|
+
onFail
|
|
452
|
+
});
|
|
453
|
+
// 같은 파일을 다시 고르면 ALREADY_UPLOADED 로 전량 반려된다
|
|
454
|
+
selectFiles(container, [makeFile('picked.jpg')]);
|
|
455
|
+
(0, _vitest.expect)(onFail).toHaveBeenCalledTimes(1);
|
|
456
|
+
(0, _vitest.expect)(onFail.mock.calls[0][0][0].errorType).toBe(_ImageFileInput.ImageFileInputErrorType.ALREADY_UPLOADED);
|
|
457
|
+
(0, _vitest.expect)(onChange).not.toHaveBeenCalled(); // 빈 배열로 덮어쓰지 않는다
|
|
458
|
+
});
|
|
459
|
+
(0, _vitest.it)('전량 반려되면 저장된 이미지도 비우지 않는다', () => {
|
|
460
|
+
const saved = {
|
|
461
|
+
fileName: 'saved.jpg',
|
|
462
|
+
fileImageUrl: 'https://external.example.com/saved.jpg'
|
|
463
|
+
};
|
|
464
|
+
const onUploadedFilesChange = _vitest.vi.fn();
|
|
465
|
+
const container = mount({
|
|
466
|
+
maxFileCount: 3,
|
|
467
|
+
value: [makeFile('picked.jpg')],
|
|
468
|
+
onChange: () => undefined,
|
|
469
|
+
uploadedFiles: [saved],
|
|
470
|
+
onUploadedFilesChange,
|
|
471
|
+
onFail: () => undefined
|
|
472
|
+
});
|
|
473
|
+
selectFiles(container, [makeFile('picked.jpg')]);
|
|
474
|
+
(0, _vitest.expect)(onUploadedFilesChange).not.toHaveBeenCalled();
|
|
475
|
+
});
|
|
476
|
+
(0, _vitest.it)('일부만 통과하면 통과분만 반영한다', () => {
|
|
477
|
+
const already = makeFile('dup.jpg');
|
|
478
|
+
const onChange = _vitest.vi.fn();
|
|
479
|
+
const container = mount({
|
|
480
|
+
maxFileCount: 5,
|
|
481
|
+
value: [already],
|
|
482
|
+
onChange,
|
|
483
|
+
onFail: () => undefined
|
|
484
|
+
});
|
|
485
|
+
const fresh = makeFile('fresh.jpg');
|
|
486
|
+
selectFiles(container, [makeFile('dup.jpg'), fresh]);
|
|
487
|
+
(0, _vitest.expect)(onChange).toHaveBeenCalledWith([already, fresh]);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
(0, _vitest.describe)('ImageFileInput — 저장된 이미지의 슬롯 수 합산', () => {
|
|
491
|
+
(0, _vitest.it)('uploadedFiles가 maxFileCount를 채우면 빈 슬롯을 감춘다', () => {
|
|
492
|
+
const container = mount({
|
|
493
|
+
maxFileCount: 2,
|
|
494
|
+
uploadedFiles: [{
|
|
495
|
+
fileName: 'a.jpg',
|
|
496
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
497
|
+
}, {
|
|
498
|
+
fileName: 'b.jpg',
|
|
499
|
+
fileImageUrl: 'https://external.example.com/b.jpg'
|
|
500
|
+
}]
|
|
501
|
+
});
|
|
502
|
+
(0, _vitest.expect)(emptySlot(container)).toBeNull();
|
|
503
|
+
});
|
|
504
|
+
(0, _vitest.it)('maxFileCount에 여유가 있으면 빈 슬롯을 노출한다', () => {
|
|
505
|
+
const container = mount({
|
|
506
|
+
maxFileCount: 3,
|
|
507
|
+
uploadedFiles: [{
|
|
508
|
+
fileName: 'a.jpg',
|
|
509
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
510
|
+
}, {
|
|
511
|
+
fileName: 'b.jpg',
|
|
512
|
+
fileImageUrl: 'https://external.example.com/b.jpg'
|
|
513
|
+
}]
|
|
514
|
+
});
|
|
515
|
+
(0, _vitest.expect)(emptySlot(container)).not.toBeNull();
|
|
516
|
+
});
|
|
517
|
+
(0, _vitest.it)('maxFileCount가 없을 때 저장된 이미지가 있으면 빈 슬롯을 감춘다', () => {
|
|
518
|
+
const container = mount({
|
|
519
|
+
uploadedFiles: [{
|
|
520
|
+
fileName: 'a.jpg',
|
|
521
|
+
fileImageUrl: 'https://external.example.com/a.jpg'
|
|
522
|
+
}]
|
|
523
|
+
});
|
|
524
|
+
(0, _vitest.expect)(emptySlot(container)).toBeNull();
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
(0, _vitest.describe)('ImageFileInput — 기존 File 동작 회귀', () => {
|
|
528
|
+
(0, _vitest.it)('uploadedFiles를 넘기지 않으면 빈 슬롯만 렌더한다', () => {
|
|
529
|
+
const container = mount({});
|
|
530
|
+
(0, _vitest.expect)(previewImages(container)).toHaveLength(0);
|
|
531
|
+
(0, _vitest.expect)(emptySlot(container)).not.toBeNull();
|
|
532
|
+
});
|
|
533
|
+
});
|